able to update desc for course

This commit is contained in:
Aleksandr Trushkin
2023-12-18 00:14:07 +03:00
parent 1d4e8e10fb
commit fbe9927ac3
9 changed files with 384 additions and 32 deletions

View File

@ -151,12 +151,18 @@ func (r *ydbCourseRepository) List(
appendTextParam("course_thematic", params.CourseThematic)
appendTextParam("learning_type", params.LearningType)
opts = append(
opts,
table.ValueParam("$id", types.TextValue(params.NextPageToken)),
table.ValueParam("$limit", types.Int32Value(int32(params.Limit))),
)
query, err := qtParams.render()
if err != nil {
return result, fmt.Errorf("rendering: %w", err)
}
xcontext.LogInfo(ctx, r.log, "planning to run query", slog.String("query", query), slog.Any("opts", opts))
xcontext.LogInfo(ctx, r.log, "planning to run query", slog.String("query", query), slog.String("opts", tableParamOptsToString(opts...)))
courses := make([]domain.Course, 0, 1_000)
readTx := table.TxControl(
@ -319,10 +325,31 @@ func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (d
return domain.Course{}, nil
}
func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
st := mapSourceTypeFromDomain(params.SourceType)
type updateCourseParams struct {
domain.CreateCourseParams
CreatedAt time.Time
DeletedAt nullable.Value[time.Time]
}
func updateCourseParamsAsStruct(params updateCourseParams) types.Value {
opts := createCourseParamsAsStructValues(params.CreateCourseParams)
now := time.Now()
return types.StructValue(
append(
opts[:len(opts)-3],
types.StructFieldValue("created_at", types.DatetimeValueFromTime(params.CreatedAt)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)),
)...,
)
}
func createCourseParamsAsStructValues(params domain.CreateCourseParams) []types.StructValueOption {
st := mapSourceTypeFromDomain(params.SourceType)
now := time.Now()
return []types.StructValueOption{
types.StructFieldValue("id", types.TextValue(params.ID)),
types.StructFieldValue("name", types.TextValue(params.Name)),
types.StructFieldValue("source_type", types.TextValue(st)),
@ -341,6 +368,12 @@ func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
types.StructFieldValue("created_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)),
}
}
func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
return types.StructValue(
createCourseParamsAsStructValues(params)...,
)
}
@ -424,6 +457,100 @@ func (r *ydbCourseRepository) Delete(ctx context.Context, id string) error {
return nil
}
func (r *ydbCourseRepository) UpdateCourseDescription(ctx context.Context, id, description string) error {
course, err := r.Get(ctx, id)
if err != nil {
return fmt.Errorf("getting course: %w", err)
}
params := updateCourseParams{
CreateCourseParams: domain.CreateCourseParams{
ID: course.ID,
ExternalID: course.ExternalID,
Name: course.Name,
SourceType: course.SourceType,
SourceName: course.SourceName,
CourseThematic: course.Thematic,
LearningType: course.LearningType,
OrganizationID: course.OrganizationID,
OriginLink: course.OriginLink,
ImageLink: course.ImageLink,
Description: description,
FullPrice: course.FullPrice,
Discount: course.Discount,
Duration: course.Duration,
StartsAt: course.StartsAt,
},
CreatedAt: course.CreatedAt,
DeletedAt: course.DeletedAt,
}
updateStruct := updateCourseParamsAsStruct(params)
const upsertQuery = `DECLARE $courseData AS List<Struct<
id: Text,
external_id: Optional<Text>,
name: Text,
source_type: Text,
source_name: Optional<Text>,
course_thematic: Text,
learning_type: Text,
organization_id: Text,
origin_link: Text,
image_link: Text,
description: Text,
full_price: Double,
discount: Double,
duration: Interval,
starts_at: Datetime,
created_at: Datetime,
updated_at: Datetime,
deleted_at: Optional<Datetime>>>;
REPLACE INTO
courses
SELECT
id,
external_id,
name,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM AS_TABLE($courseData);`
writeTx := table.TxControl(
table.BeginTx(
table.WithSerializableReadWrite(),
),
table.CommitTx(),
)
err = r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
queryParams := table.NewQueryParameters(
table.ValueParam("$courseData", types.ListValue(updateStruct)),
)
_, _, err := s.Execute(ctx, writeTx, upsertQuery, queryParams)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
})
return err
}
func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error {
return r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
return s.CreateTable(
@ -595,3 +722,12 @@ WHERE {{ range .Conditions }}{{.}}{{end}}
{{.Suffix}}`
var querySelect = template.Must(template.New("").Parse(queryTemplateSelect))
func tableParamOptsToString(in ...table.ParameterOption) string {
var sb strings.Builder
for _, opt := range in {
sb.WriteString(opt.Name() + "(" + opt.Value().Type().String() + ");")
}
return sb.String()
}

View File

@ -6,9 +6,10 @@ import (
)
type Commands struct {
InsertCourses command.CreateCoursesHandler
InsertCourse command.CreateCourseHandler
DeleteCourse command.DeleteCourseHandler
InsertCourses command.CreateCoursesHandler
InsertCourse command.CreateCourseHandler
DeleteCourse command.DeleteCourseHandler
UpdateCourseDescription command.UpdateCourseDescriptionHandler
}
type Queries struct {

View File

@ -0,0 +1,35 @@
package command
import (
"context"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type UpdateCourseDescription struct {
ID string
Description string
}
type UpdateCourseDescriptionHandler decorator.CommandHandler[UpdateCourseDescription]
type updateCourseDescriptionHandler struct {
repo domain.CourseRepository
}
func NewUpdateCourseDescriptionHandler(
repo domain.CourseRepository,
log *slog.Logger,
) UpdateCourseDescriptionHandler {
h := updateCourseDescriptionHandler{
repo: repo,
}
return decorator.ApplyCommandDecorators(h, log)
}
func (h updateCourseDescriptionHandler) Handle(ctx context.Context, cmd UpdateCourseDescription) error {
return h.repo.UpdateCourseDescription(ctx, cmd.ID, cmd.Description)
}

View File

@ -56,6 +56,9 @@ type CourseRepository interface {
Create(context.Context, CreateCourseParams) (Course, error)
// Delete course by id.
Delete(ctx context.Context, id string) error
// UpdateCourseDescription is a temporary method to udpate description field
UpdateCourseDescription(ctx context.Context, id string, description string) error
}
type CreateOrganizationParams struct {

View File

@ -1,15 +1,21 @@
package http
import (
"encoding/json"
"log/slog"
"net/http"
"sort"
"strconv"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/internal/kurious/service"
"github.com/gorilla/mux"
)
type courseServer struct {
@ -108,7 +114,14 @@ func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams {
outCategory.Subcategories = append(outCategory.Subcategories, outSubCategory)
}
sort.Slice(outCategory.Subcategories, func(i, j int) bool {
return outCategory.Subcategories[i].ID < outCategory.Subcategories[j].ID
})
out.Categories = append(out.Categories, outCategory)
}
sort.Slice(out.Categories, func(i, j int) bool {
return out.Categories[i].ID < out.Categories[j].ID
})
return out
}
@ -139,3 +152,52 @@ func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
return
}
}
func (c courseServer) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := mux.Vars(r)["course_id"]
course, err := c.app.Queries.GetCourse.Handle(ctx, query.GetCourse{
ID: id,
})
if handleError(ctx, err, w, c.log, "unable to get course") {
return
}
payload, err := json.MarshalIndent(course, "", " ")
if handleError(ctx, err, w, c.log, "unable to marshal json") {
return
}
w.Header().Set("content-type", "application/json")
w.Header().Set("content-length", strconv.Itoa(len(payload)))
_, err = w.Write([]byte(payload))
if err != nil {
xcontext.LogWithWarnError(ctx, c.log, err, "unable to write a message")
}
}
func (c courseServer) UdpateDescription(w http.ResponseWriter, r *http.Request) {
type requestModel struct {
ID string `json:"id"`
Text string `json:"text"`
}
ctx := r.Context()
var req requestModel
err := json.NewDecoder(r.Body).Decode(&req)
if handleError(ctx, err, w, c.log, "unable to read body") {
return
}
err = c.app.Commands.UpdateCourseDescription.Handle(ctx, command.UpdateCourseDescription{
ID: req.ID,
Description: req.Text,
})
if handleError(ctx, err, w, c.log, "unable to update course description") {
return
}
w.WriteHeader(http.StatusOK)
}

View File

@ -0,0 +1,60 @@
package http
import (
"strconv"
"strings"
"testing"
"time"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
var courses = func() []domain.Course {
out := make([]domain.Course, 0)
out = append(out, makeBatchCourses("prog", []string{"go", "rust"}, 4)...)
out = append(out, makeBatchCourses("front", []string{"js", "html"}, 4)...)
return out
}()
func makeBatchCourses(lt string, cts []string, num int) []domain.Course {
out := make([]domain.Course, 0, len(cts)*num)
for _, ct := range cts {
for i := 0; i < num; i++ {
name := strings.Join([]string{
lt, ct,
strconv.Itoa(i),
}, ".")
out = append(out, makeCourse(lt, ct, name))
}
}
return out
}
func makeCourse(lt, ct, name string) domain.Course {
return domain.Course{
LearningType: lt,
Thematic: ct,
Name: name,
ID: lt + ct + name,
FullPrice: 123,
Duration: time.Second * 100,
StartsAt: time.Now(),
}
}
func TestRenderTemplate(t *testing.T) {
t.SkipNow()
result := mapDomainCourseToTemplate(courses...)
t.Logf("%#v", result)
var out strings.Builder
err := listTemplateParsed.ExecuteTemplate(&out, "courses", result)
if err != nil {
t.Fatalf("executing: %v", err)
}
t.Log(out.String())
t.Fail()
}

View File

@ -45,13 +45,21 @@ const listTemplate = `{{define "courses"}}
p {
margin-bottom: 10px;
}
.main-course {
background-color: #3B4252;
color: #E5E9F0;
text-align: center;
}
.sub-course {
background-color: #4C566A;
color: #ECEFF4;
}
.course-plate {
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
text-align: center;
}
.course-plate a {
color: #333;
@ -60,6 +68,14 @@ const listTemplate = `{{define "courses"}}
.course-plate a:hover {
text-decoration: underline;
}
.editable-text {
cursor: pointer;
}
.editable-text.editing {
border: 1px solid #000;
padding: 5px;
width: 100%;
}
</style>
</head>
<body>
@ -73,27 +89,61 @@ const listTemplate = `{{define "courses"}}
</ul>
</nav>
</header>
<h1>Courses</h1>
{{range $category := .Categories}}
<h2>{{$category.Name}}</h2>
<p>{{$category.Description}}</p>
{{range $subcategory := .Subcategories}}
<h2>{{$subcategory.Name}}</h2>
<p>{{$subcategory.Description}}</p>
{{range $course := $.Courses}}
<div class="course-plate">
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3>
<p>{{$course.Description}}</p>
<p>Full price: {{$course.FullPrice}}</p>
<p>Discount: {{$course.Discount}}</p>
<p>Thematic: {{$course.Thematic}}</p>
<p>Learning type: {{$course.LearningType}}</p>
<p>Duration: {{$course.Duration}}</p>
<p>Starts at: {{$course.StartsAt}}</p>
</div>
{{range $category := .Categories}}
<h2 class="main-course">Category {{$category.Name}}</h2>
<p> Course Description: {{$category.Description}}</p>
{{range $subcategory := $category.Subcategories}}
<div>
<h2 class="sub-course"> Subcategory: {{$subcategory.Name}}</h2>
<p>Subcategory Description: {{$subcategory.Description}}</p>
{{range $course := $subcategory.Courses}}
<div class="course-plate">
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3>
<p>Description: <div id="editable-text-{{$course.ID}}" class="editable-text" contenteditable=false>{{or $course.Description "..."}}</div></p>
<p>Full price: {{$course.FullPrice}}</p>
<p>Discount: {{$course.Discount}}</p>
<p>Thematic: {{$course.Thematic}}</p>
<p>Learning type: {{$course.LearningType}}</p>
<p>Duration: {{$course.Duration}}</p>
<p>Starts at: {{$course.StartsAt}}</p>
</div>
</div>
{{end}}
{{end}}
{{end}}
{{end}}
{{end}}
<script>
const editableTexts = document.querySelectorAll('.editable-text');
let isEditing = false;
editableTexts.forEach(function(editableText) {
editableText.addEventListener('click', function() {
if (!isEditing) {
editableText.contentEditable = 'true';
editableText.className += ' editing';
isEditing = true;
}
});
editableText.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const text = editableText.innerText;
const id = editableText.id.replace('editable-text-', ''); // Extract the ID from the element's ID
// Send a POST request with JSON data
const xhr = new XMLHttpRequest();
xhr.open('POST', '/updatedesc', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ text, id }));
editableText.contentEditable = 'false';
editableText.className = 'editable-text';
isEditing = false;
}
});
});
</script>
</body>
</html>
{{end}}`

View File

@ -36,9 +36,10 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, er
application := app.Application{
Commands: app.Commands{
InsertCourses: command.NewCreateCoursesHandler(courseadapter, log),
InsertCourse: command.NewCreateCourseHandler(courseadapter, log),
DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log),
InsertCourses: command.NewCreateCoursesHandler(courseadapter, log),
InsertCourse: command.NewCreateCourseHandler(courseadapter, log),
DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log),
UpdateCourseDescription: command.NewUpdateCourseDescriptionHandler(courseadapter, log),
},
Queries: app.Queries{
GetCourse: query.NewGetCourseHandler(courseadapter, log),