Questions system WIP

This commit is contained in:
Noah 2018-12-23 16:18:04 -08:00
parent eae499c640
commit 517a2ee86b
7 changed files with 400 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import (
commentctl "github.com/kirsle/blog/internal/controllers/comments" commentctl "github.com/kirsle/blog/internal/controllers/comments"
"github.com/kirsle/blog/internal/controllers/contact" "github.com/kirsle/blog/internal/controllers/contact"
postctl "github.com/kirsle/blog/internal/controllers/posts" postctl "github.com/kirsle/blog/internal/controllers/posts"
questionsctl "github.com/kirsle/blog/internal/controllers/questions"
"github.com/kirsle/blog/internal/controllers/setup" "github.com/kirsle/blog/internal/controllers/setup"
"github.com/kirsle/blog/internal/log" "github.com/kirsle/blog/internal/log"
"github.com/kirsle/blog/internal/markdown" "github.com/kirsle/blog/internal/markdown"
@ -28,6 +29,7 @@ import (
"github.com/kirsle/blog/jsondb/caches/redis" "github.com/kirsle/blog/jsondb/caches/redis"
"github.com/kirsle/blog/models/comments" "github.com/kirsle/blog/models/comments"
"github.com/kirsle/blog/models/posts" "github.com/kirsle/blog/models/posts"
"github.com/kirsle/blog/models/questions"
"github.com/kirsle/blog/models/settings" "github.com/kirsle/blog/models/settings"
"github.com/kirsle/blog/models/users" "github.com/kirsle/blog/models/users"
"github.com/shurcooL/github_flavored_markdown/gfmstyle" "github.com/shurcooL/github_flavored_markdown/gfmstyle"
@ -104,6 +106,7 @@ func (b *Blog) Configure() {
posts.DB = b.jsonDB posts.DB = b.jsonDB
users.DB = b.jsonDB users.DB = b.jsonDB
comments.DB = b.jsonDB comments.DB = b.jsonDB
questions.UseDB(b.db)
// Redis cache? // Redis cache?
if config.Redis.Enabled { if config.Redis.Enabled {
@ -136,6 +139,7 @@ func (b *Blog) SetupHTTP() {
contact.Register(r) contact.Register(r)
postctl.Register(r, b.MustLogin) postctl.Register(r, b.MustLogin)
commentctl.Register(r) commentctl.Register(r)
questionsctl.Register(r)
// GitHub Flavored Markdown CSS. // GitHub Flavored Markdown CSS.
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))

View File

@ -0,0 +1,137 @@
package questions
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/kirsle/blog/internal/log"
"github.com/kirsle/blog/internal/mail"
"github.com/kirsle/blog/internal/markdown"
"github.com/kirsle/blog/internal/render"
"github.com/kirsle/blog/internal/responses"
"github.com/kirsle/blog/internal/sessions"
"github.com/kirsle/blog/models/comments"
"github.com/kirsle/blog/models/questions"
"github.com/kirsle/blog/models/settings"
"github.com/kirsle/blog/models/users"
)
var badRequest func(http.ResponseWriter, *http.Request, string)
// Register the comment routes to the app.
func Register(r *mux.Router) {
badRequest = responses.BadRequest
r.HandleFunc("/ask", questionsHandler)
}
// CommentMeta is the template variables for comment threads.
type CommentMeta struct {
NewComment comments.Comment
ID string
OriginURL string // URL where original comment thread appeared
Subject string // email subject
Thread *comments.Thread
Authors map[int]*users.User
CSRF string
}
func questionsHandler(w http.ResponseWriter, r *http.Request) {
submit := r.FormValue("submit")
// Share their name and email with the commenting system.
session := sessions.Get(r)
name, _ := session.Values["c.name"].(string)
email, _ := session.Values["c.email"].(string)
Q := questions.New()
Q.Name = name
Q.Email = email
cfg, err := settings.Load()
if err != nil {
responses.Error(w, r, "Error loading site configuration!")
return
}
v := map[string]interface{}{}
// Previewing, deleting, or posting?
if r.Method == http.MethodPost {
Q.ParseForm(r)
log.Info("Q: %+v", Q)
switch submit {
case "ask":
if err := Q.Validate(); err != nil {
log.Debug("Validation error on question form: %s", err.Error())
v["Error"] = err
} else {
// Cache their name and email in their session.
session.Values["c.name"] = Q.Name
session.Values["c.email"] = Q.Email
session.Save(r, w)
// Append their comment.
err := Q.Save()
if err != nil {
log.Error("Error saving new question: %s", err.Error())
responses.FlashAndRedirect(w, r, "/ask", "Error saving question: %s", err)
return
}
// Email the site admin.
subject := fmt.Sprintf("Ask Me Anything (%s) from %s", cfg.Site.Title, Q.Name)
log.Info("Emailing site admin about this question")
go mail.SendEmail(mail.Email{
To: cfg.Site.AdminEmail,
Admin: true,
ReplyTo: Q.Email,
Subject: subject,
Template: ".email/generic.gohtml",
Data: map[string]interface{}{
"Subject": subject,
"Message": template.HTML(
markdown.RenderMarkdown(Q.Question) + "\n\n" +
"Answer this at " + strings.Trim(cfg.Site.URL, "/") + "/ask",
),
},
})
// Log it to disk, too.
fh, err := os.OpenFile(filepath.Join(*render.UserRoot, ".questions.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
responses.Flash(w, r, "Error logging the message to disk: %s", err)
} else {
fh.WriteString(fmt.Sprintf(
"Date: %s\nName: %s\nEmail: %s\n\n%s\n\n--------------------\n\n",
time.Now().Format(time.UnixDate),
Q.Name,
Q.Email,
Q.Question,
))
fh.Close()
}
log.Info("Recorded question from %s: %s", Q.Name, Q.Question)
responses.FlashAndRedirect(w, r, "/ask", "Your question has been recorded!")
return
}
case "answer":
case "delete":
default:
responses.FlashAndRedirect(w, r, "/ask", "Unknown submit action.")
return
}
}
v["Q"] = Q
render.Template(w, r, "questions.gohtml", v)
}

View File

@ -0,0 +1,16 @@
/*
Package questions implements the "Ask Me Anything" feature.
Routes
/ask Ask Me Anything
Related Models
questions
Description
Pending description.
*/
package questions

11
models/questions/enums.go Normal file
View File

@ -0,0 +1,11 @@
package questions
// Status of a Question.
type Status string
// Status options.
const (
Pending = "pending"
Answered = "answered"
Deleted = "deleted"
)

View File

@ -0,0 +1,126 @@
package questions
import (
"errors"
"net/http"
"net/mail"
"strconv"
"time"
"github.com/jinzhu/gorm"
"github.com/kirsle/golog"
)
// DB is a reference to the parent app's gorm DB.
var DB *gorm.DB
// UseDB registers the DB from the root app.
func UseDB(db *gorm.DB) {
DB = db
DB.AutoMigrate(&Question{})
}
var log *golog.Logger
func init() {
log = golog.GetLogger("blog")
}
// Question is a question asked of the blog owner.
type Question struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Question string `json:"question"`
Status Status `json:"status"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// New creates a blank Question with sensible defaults.
func New() *Question {
return &Question{
Status: Pending,
}
}
// All returns all the Questions.
func All() ([]*Question, error) {
result := []*Question{}
err := DB.Order("start_time desc").Find(&result).Error
return result, err
}
// ParseForm populates the Question from form values.
func (ev *Question) ParseForm(r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))
ev.ID = id
ev.Name = r.FormValue("name")
ev.Email = r.FormValue("email")
ev.Question = r.FormValue("question")
}
// parseDateTime parses separate date + time fields into a single time.Time.
func parseDateTime(r *http.Request, dateField, timeField string) (time.Time, error) {
dateValue := r.FormValue(dateField)
timeValue := r.FormValue(timeField)
if dateValue != "" && timeValue != "" {
datetime, err := time.Parse("2006-01-02 15:04", dateValue+" "+timeValue)
return datetime, err
} else if dateValue != "" {
datetime, err := time.Parse("2006-01-02", dateValue)
return datetime, err
} else {
return time.Time{}, errors.New("no date/times given")
}
}
// Validate makes sure the required fields are all present.
func (ev *Question) Validate() error {
if ev.Question == "" {
return errors.New("question is required")
}
if ev.Email != "" {
if _, err := mail.ParseAddress(ev.Email); err != nil {
return err
}
}
return nil
}
// Load an Question by its ID.
func Load(id int) (*Question, error) {
ev := &Question{}
err := DB.First(ev, id).Error
return ev, err
}
// Save the Question.
func (ev *Question) Save() error {
if ev.Name == "" {
ev.Name = "Anonymous"
}
// Dates & times.
if ev.Created.IsZero() {
ev.Created = time.Now().UTC()
}
if ev.Updated.IsZero() {
ev.Updated = ev.Created
}
// Write the Question.
return DB.Save(&ev).Error
}
// Delete an Question.
func (ev *Question) Delete() error {
if ev.ID == 0 {
return errors.New("Question has no ID")
}
// Delete the DB files.
return DB.Delete(ev).Error
}

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
<title>{{ .Subject }}</title>
</head>
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
<center>
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
<b>{{ .Subject }}</b>
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#FEFEFE">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
{{ .Data.Message }}
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
This e-mail was automatically generated; do not reply to it.
</font>
</td>
</tr>
</table>
</center>
</body>
</html>

67
root/questions.gohtml Normal file
View File

@ -0,0 +1,67 @@
{{ define "title" }}Ask Me Anything{{ end }}
{{ define "content" }}
<h1>Ask Me Anything</h1>
{{ if .Data.Error }}
<div class="alert alert-danger">
Error: {{ .Data.Error }}
</div>
{{ end }}
<form name="askme" method="POST" action="/ask">
<div class="card">
<div class="card-body">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="form-group row">
<div class="col-12 col-md-2">
<label class="col-form-label" for="name">Name:</label>
</div>
<div class="col-12 col-md-10">
<input type="text" class="form-control" id="name" name="name" value="{{ .Data.Q.Name }}" placeholder="Anonymous">
</div>
</div>
<div class="form-group row">
<div class="col-12 col-md-2">
<label class="col-form-label" for="email">Email:</label>
</div>
<div class="col-12 col-md-10">
<div><input type="email" class="form-control" id="email" name="email" value="{{ .Data.Q.Email }}" placeholder="name@example.com"></div>
<small>
Optional. You will receive a one-time e-mail when I answer your question and no spam.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-12" for="question">Question: <small>(required)</small></label>
<textarea cols="80" rows="6"
class="col-12 form-control"
name="question"
id="question"
placeholder="Ask me anything">{{ .Data.Q.Question }}</textarea>
</div>
<div class="form-group row">
<div class="col">
<button type="submit"
name="submit"
value="ask"
class="btn btn-primary">Ask away!</button>
</div>
</div>
</div>
</div>
{{ if .LoggedIn }}
<div class="card mt-4">
<div class="card-header">
Pending Questions
</div>
<div class="card-body">
xxx
</div>
</div>
{{ end }}
</form>
{{ end }}