From 605e1175860a2b28be0406551e5d0686c3bca653 Mon Sep 17 00:00:00 2001 From: Aleksandr Trushkin Date: Sun, 7 Apr 2024 23:49:06 +0300 Subject: [PATCH] add pagination --- .task/checksum/generate | 2 +- cmd/kuriweb/http.go | 20 +- cmd/kuriweb/trace.go | 6 +- htmlexamples/index.html | 70 ++--- internal/common/decorator/logging.go | 1 + internal/kurious/adapters/memory_mapper.go | 55 +++- .../adapters/sqlite_course_repository.go | 71 ++++- .../adapters/sqlite_course_repository_test.go | 40 +++ internal/kurious/app/app.go | 9 +- internal/kurious/app/query/listcourses.go | 20 +- .../kurious/app/query/listcoursesstats.go | 38 +++ internal/kurious/domain/mapper.go | 1 + internal/kurious/domain/repository.go | 16 +- .../kurious/ports/http/bootstrap/core.templ | 2 +- .../ports/http/bootstrap/core_templ.go | 2 +- .../kurious/ports/http/bootstrap/list.templ | 1 + .../ports/http/bootstrap/list_templ.go | 8 + .../kurious/ports/http/bootstrap/main.templ | 68 ++++- .../ports/http/bootstrap/main_templ.go | 256 ++++++++++++++++-- internal/kurious/ports/http/bootstrap/vars.go | 24 ++ internal/kurious/ports/http/course.go | 53 ++-- internal/kurious/ports/http/server.go | 20 +- internal/kurious/service/service.go | 9 +- 23 files changed, 639 insertions(+), 153 deletions(-) create mode 100644 internal/kurious/app/query/listcoursesstats.go diff --git a/.task/checksum/generate b/.task/checksum/generate index 9e85368..db3ce01 100644 --- a/.task/checksum/generate +++ b/.task/checksum/generate @@ -1 +1 @@ -3cf236a901d03e42352790df844d58c5 +de22f926e2940e47830ef3a33736db71 diff --git a/cmd/kuriweb/http.go b/cmd/kuriweb/http.go index 6814419..faf2f90 100644 --- a/cmd/kuriweb/http.go +++ b/cmd/kuriweb/http.go @@ -43,9 +43,9 @@ func setupCoursesHTTP(srv xhttp.Server, router *mux.Router, _ *slog.Logger) { coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam) coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam) - muxHandleFunc(coursesRouter, "/", coursesAPI.Index).Methods(http.MethodGet) - muxHandleFunc(coursesRouter, coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet) - muxHandleFunc(coursesRouter, coursesListFullPath, coursesAPI.List).Methods(http.MethodGet) + muxHandleFunc(coursesRouter, "index", "/", coursesAPI.Index).Methods(http.MethodGet) + muxHandleFunc(coursesRouter, "list_learning", coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet) + muxHandleFunc(coursesRouter, "list_full", coursesListFullPath, coursesAPI.List).Methods(http.MethodGet) } func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server { @@ -111,6 +111,7 @@ func middlewareTrace() mux.MiddlewareFunc { reqidAttr := attribute.Key("http.request_id") statusAttr := attribute.Key("http.status_code") payloadAttr := attribute.Key("http.payload_size") + pathAttr := attribute.Key("http.template_path") return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -118,7 +119,16 @@ func middlewareTrace() mux.MiddlewareFunc { reqid := xcontext.GetRequestID(ctx) var span trace.Span - ctx, span = webtracer.Start(ctx, r.URL.String(), trace.WithAttributes(reqidAttr.String(reqid))) + route := mux.CurrentRoute(r) + hname := route.GetName() + hpath, _ := route.GetPathTemplate() + ctx, span = webtracer.Start( + ctx, "http."+hname, + trace.WithAttributes( + reqidAttr.String(reqid), + pathAttr.String(hpath), + ), + ) defer span.End() next.ServeHTTP(w, r.WithContext(ctx)) @@ -131,6 +141,8 @@ func middlewareTrace() mux.MiddlewareFunc { ) if statusCode > 399 { span.SetStatus(codes.Error, "error during request") + } else { + span.SetStatus(codes.Ok, "request completed") } } }) diff --git a/cmd/kuriweb/trace.go b/cmd/kuriweb/trace.go index c1ac2e9..b9202de 100644 --- a/cmd/kuriweb/trace.go +++ b/cmd/kuriweb/trace.go @@ -22,7 +22,7 @@ import ( "go.opentelemetry.io/otel/sdk/trace" ) -var webtracer = otel.Tracer("kuriweb") +var webtracer = otel.Tracer("kuriweb.http") type shutdownFunc func(context.Context) error @@ -126,7 +126,7 @@ func newMeterProvider() (*metric.MeterProvider, error) { return meterProvider, nil } -func muxHandleFunc(router *mux.Router, path string, hf http.HandlerFunc) *mux.Route { +func muxHandleFunc(router *mux.Router, name, path string, hf http.HandlerFunc) *mux.Route { h := otelhttp.WithRouteTag(path, hf) - return router.Handle(path, h) + return router.Handle(path, h).Name(name) } diff --git a/htmlexamples/index.html b/htmlexamples/index.html index c67f8a1..3597734 100644 --- a/htmlexamples/index.html +++ b/htmlexamples/index.html @@ -44,7 +44,7 @@

Here you can find course for any taste

-
+
@@ -55,6 +55,11 @@

In this category you can find courses of types such as

web-development, backend development, frontend developent

+
    +
  • web-development
  • +
  • backend development
  • +
  • frontend development
  • +

This category contains 128 courses.

-
-
-
-
    -

    Category 1

    -
  • -
    item
    -
  • -
  • -
    item
    -
  • -
  • -
    item
    -
  • -
-
-
-
-
-
    -

    Category 2

    -
  • -
    item
    -
  • -
  • -
    item
    -
  • -
  • -
    item
    -
  • -
-
-
-
-
-
    -

    Category 3

    -
  • -
    item
    -
  • -
  • -
    item
    -
  • -
  • -
    item
    -
  • -
-
-
-
+
diff --git a/internal/common/decorator/logging.go b/internal/common/decorator/logging.go index 28d72e7..3fc3285 100644 --- a/internal/common/decorator/logging.go +++ b/internal/common/decorator/logging.go @@ -9,6 +9,7 @@ import ( "time" "git.loyso.art/frx/kurious/internal/common/xcontext" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" diff --git a/internal/kurious/adapters/memory_mapper.go b/internal/kurious/adapters/memory_mapper.go index 42e5936..886709d 100644 --- a/internal/kurious/adapters/memory_mapper.go +++ b/internal/kurious/adapters/memory_mapper.go @@ -11,9 +11,9 @@ type inMemoryMapper struct { courseThematicsByID map[string]string learningTypeByID map[string]string - courseThematicsCountByID map[string]int - learningTypeCountByID map[string]int - totalCount int + stats map[string]domain.LearningTypeStat + courseThematicByLearningType map[string]string + totalCount int } func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryMapper { @@ -26,8 +26,8 @@ func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryM func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepository) error { const batchSize = 1000 - m.courseThematicsCountByID = map[string]int{} - m.learningTypeCountByID = map[string]int{} + m.stats = map[string]domain.LearningTypeStat{} + m.courseThematicByLearningType = map[string]string{} var nextPageToken string for { @@ -42,8 +42,14 @@ func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepo } m.totalCount += len(result.Courses) for _, course := range result.Courses { - m.courseThematicsCountByID[course.ThematicID]++ - m.learningTypeCountByID[course.LearningTypeID]++ + stat, ok := m.stats[course.LearningTypeID] + stat.Count++ + if !ok { + stat.CourseThematic = map[string]int{} + } + stat.CourseThematic[course.ThematicID]++ + m.stats[course.LearningTypeID] = stat + m.courseThematicByLearningType[course.ThematicID] = course.LearningTypeID } if len(result.Courses) < batchSize { break @@ -55,13 +61,36 @@ func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepo } func (m *inMemoryMapper) GetCounts(byCourseThematic, byLearningType string) int { - if byCourseThematic != "" { - return m.courseThematicsCountByID[byCourseThematic] - } else if byLearningType != "" { - return m.learningTypeCountByID[byLearningType] - } else { - return m.totalCount + if byCourseThematic != "" && byLearningType == "" { + byLearningType = m.courseThematicByLearningType[byCourseThematic] } + if byLearningType != "" { + stat := m.stats[byLearningType] + if byCourseThematic != "" { + return stat.CourseThematic[byCourseThematic] + } + return stat.Count + } + return m.totalCount +} + +func (m *inMemoryMapper) GetStats(copyMap bool) map[string]domain.LearningTypeStat { + if !copyMap { + return m.stats + } + + out := make(map[string]domain.LearningTypeStat, len(m.stats)) + for learningType, stats := range m.stats { + copiedStats := make(map[string]int, len(stats.CourseThematic)) + for courseThematic, count := range stats.CourseThematic { + copiedStats[courseThematic] = count + } + out[learningType] = domain.LearningTypeStat{ + Count: stats.Count, + CourseThematic: copiedStats, + } + } + return out } func (m *inMemoryMapper) CourseThematicNameByID(id string) string { diff --git a/internal/kurious/adapters/sqlite_course_repository.go b/internal/kurious/adapters/sqlite_course_repository.go index e41de1b..ea6ae5e 100644 --- a/internal/kurious/adapters/sqlite_course_repository.go +++ b/internal/kurious/adapters/sqlite_course_repository.go @@ -11,14 +11,19 @@ import ( "git.loyso.art/frx/kurious/internal/common/config" "git.loyso.art/frx/kurious/internal/common/nullable" + "git.loyso.art/frx/kurious/internal/common/xcontext" "git.loyso.art/frx/kurious/internal/kurious/domain" "git.loyso.art/frx/kurious/migrations/sqlite" "git.loyso.art/frx/kurious/pkg/xdefault" "github.com/jmoiron/sqlx" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" _ "modernc.org/sqlite" ) +var sqliteTracer = otel.Tracer("sqlite") + type sqliteConnection struct { db *sqlx.DB shutdownTimeout time.Duration @@ -68,8 +73,20 @@ func (r *sqliteCourseRepository) List( ) (result domain.ListCoursesResult, err error) { const queryTemplate = `SELECT %s from courses WHERE 1=1` + ctx, span := sqliteTracer.Start(ctx, "sqlite.list") + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + if params.NextPageToken != "" && params.Offset > 0 { + panic("could not use next_page_token and offset at the same time") + } + query := fmt.Sprintf(queryTemplate, coursesFieldsStr) - args := make([]any, 0, 1) + args := make([]any, 0, 6) if params.LearningType != "" { args = append(args, params.LearningType) query += " AND learning_type = ?" @@ -87,13 +104,20 @@ func (r *sqliteCourseRepository) List( query += " AND id > ?" } - query += " ORDER BY id ASC" + query += " ORDER BY course_thematic, learning_type, id ASC" if params.Limit > 0 { query += " LIMIT ?" args = append(args, params.Limit) } + if params.Offset > 0 { + query += " OFFSET ?" + args = append(args, params.Offset) + } + span.SetAttributes( + attribute.String("query", query), + ) scanF := func(s rowsScanner) (err error) { var cdb sqliteCourseDB err = s.StructScan(&cdb) @@ -114,6 +138,15 @@ func (r *sqliteCourseRepository) List( result.NextPageToken = result.Courses[lastIDx].ID } + result.Count, err = r.listCount(ctx, params) + if err != nil { + xcontext.LogWithWarnError(ctx, r.log, err, "unable to list count") + } + + span.SetAttributes( + attribute.Int("items_count", len(result.Courses)), + attribute.Int("total_items", result.Count), + ) return result, nil } @@ -223,6 +256,40 @@ func (r *sqliteCourseRepository) Delete(ctx context.Context, id string) error { return errors.New("unimplemented") } +func (r *sqliteCourseRepository) listCount(ctx context.Context, params domain.ListCoursesParams) (count int, err error) { + const queryTemplate = `SELECT COUNT(id) FROM courses WHERE 1=1` + + ctx, span := sqliteTracer.Start(ctx, "sqlite.listCount") + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + query := queryTemplate + args := make([]any, 0, 6) + + if params.LearningType != "" { + args = append(args, params.LearningType) + query += " AND learning_type = ?" + } + if params.CourseThematic != "" { + args = append(args, params.CourseThematic) + query += " AND course_thematic = ?" + } + if params.OrganizationID != "" { + args = append(args, params.OrganizationID) + query += " AND organization_id = ?" + } + + err = r.db.GetContext(ctx, &count, query, args...) + if err != nil { + return count, fmt.Errorf("sending query: %w", err) + } + return count, nil +} + type rowsScanner interface { sqlx.ColScanner diff --git a/internal/kurious/adapters/sqlite_course_repository_test.go b/internal/kurious/adapters/sqlite_course_repository_test.go index 9b8b9b5..4953abb 100644 --- a/internal/kurious/adapters/sqlite_course_repository_test.go +++ b/internal/kurious/adapters/sqlite_course_repository_test.go @@ -1,6 +1,7 @@ package adapters import ( + "strconv" "testing" "time" @@ -74,3 +75,42 @@ func (s *sqliteCourseRepositorySuite) TestCreateCourse() { s.Require().Equal(expcourse, gotCourse) } + +func (s *sqliteCourseRepositorySuite) TestListLimitOffset() { + const coursecount = 9 + const listparts = 3 + basecourse := domain.CreateCourseParams{ + SourceType: domain.SourceTypeManual, + } + + cr := s.connection.CourseRepository() + for i := 0; i < coursecount; i++ { + basecourse.ID = strconv.Itoa(i) + _, err := cr.Create(s.ctx, basecourse) + s.NoError(err) + } + + params := domain.ListCoursesParams{ + Limit: coursecount / listparts, + } + for i := 0; i < listparts; i++ { + result, err := cr.List(s.ctx, params) + s.NoError(err) + s.Len(result.Courses, listparts) + + params.Offset += listparts + } + + result, err := cr.List(s.ctx, params) + s.NoError(err) + s.Empty(result.Courses) + + params.Offset = 0 + for i := 0; i < listparts; i++ { + result, err := cr.List(s.ctx, params) + s.NoError(err) + s.Len(result.Courses, listparts) + + params.NextPageToken = result.NextPageToken + } +} diff --git a/internal/kurious/app/app.go b/internal/kurious/app/app.go index 7ffc270..cf3e978 100644 --- a/internal/kurious/app/app.go +++ b/internal/kurious/app/app.go @@ -15,10 +15,11 @@ type Commands struct { } type Queries struct { - GetCourse query.GetCourseHandler - ListCourses query.ListCourseHandler - ListLearningTypes query.ListLearningTypesHandler - ListCourseThematics query.ListCourseThematicsHandler + GetCourse query.GetCourseHandler + ListCourses query.ListCourseHandler + ListLearningTypes query.ListLearningTypesHandler + ListCourseThematics query.ListCourseThematicsHandler + ListCourseStatistics query.ListCoursesStatsHandler ListOrganzations query.ListOrganizationsHandler GetOrganization query.GetOrganizationHandler diff --git a/internal/kurious/app/query/listcourses.go b/internal/kurious/app/query/listcourses.go index 3c8efe9..0bcbc3c 100644 --- a/internal/kurious/app/query/listcourses.go +++ b/internal/kurious/app/query/listcourses.go @@ -18,6 +18,7 @@ type ListCourse struct { Keyword string Limit int + Offset int NextPageToken string } @@ -41,11 +42,14 @@ func NewListCourseHandler( } func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out domain.ListCoursesResult, err error) { - out.AvailableCoursesOfSub = map[string]int{} + const defaultBatchSize = 1_000 + out.NextPageToken = query.NextPageToken drainFull := query.Limit == 0 if !drainFull { out.Courses = make([]domain.Course, 0, query.Limit) + } else { + query.Limit = defaultBatchSize } for { result, err := h.repo.List(ctx, domain.ListCoursesParams{ @@ -53,8 +57,8 @@ func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out do LearningType: query.LearningType, OrganizationID: query.OrganizationID, Limit: query.Limit, - - NextPageToken: out.NextPageToken, + Offset: query.Offset, + NextPageToken: out.NextPageToken, }) if err != nil { return out, fmt.Errorf("listing courses: %w", err) @@ -69,19 +73,15 @@ func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out do out.Courses = append(out.Courses, result.Courses...) out.NextPageToken = result.NextPageToken + out.Count = result.Count - if drainFull && len(result.Courses) > 0 && result.NextPageToken != "" { + if drainFull && len(result.Courses) == query.Limit { + query.Offset += query.Limit continue } break } - for _, course := range out.Courses { - if _, ok := out.AvailableCoursesOfSub[course.ThematicID]; !ok { - out.AvailableCoursesOfSub[course.ThematicID] = h.mapper.GetCounts(course.ThematicID, course.LearningTypeID) - } - } - return out, nil } diff --git a/internal/kurious/app/query/listcoursesstats.go b/internal/kurious/app/query/listcoursesstats.go new file mode 100644 index 0000000..224f756 --- /dev/null +++ b/internal/kurious/app/query/listcoursesstats.go @@ -0,0 +1,38 @@ +package query + +import ( + "context" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/decorator" + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +type ListCoursesStats struct{} +type ListCoursesStatsResult struct{} + +type ListCoursesStatsHandler decorator.QueryHandler[ListCoursesStats, domain.ListCoursesStatsResult] + +type listCoursesStatsHandler struct { + mapper domain.CourseMapper +} + +func NewListCoursesStatsHandler( + mapper domain.CourseMapper, + log *slog.Logger, +) ListCoursesStatsHandler { + h := listCoursesStatsHandler{ + mapper: mapper, + } + + return decorator.AddQueryDecorators(h, log) +} + +func (h listCoursesStatsHandler) Handle( + ctx context.Context, + query ListCoursesStats, +) (out domain.ListCoursesStatsResult, err error) { + stats := h.mapper.GetStats(false) + out.StatsByLearningType = stats + return out, nil +} diff --git a/internal/kurious/domain/mapper.go b/internal/kurious/domain/mapper.go index f637925..3b75443 100644 --- a/internal/kurious/domain/mapper.go +++ b/internal/kurious/domain/mapper.go @@ -8,4 +8,5 @@ type CourseMapper interface { CollectCounts(context.Context, CourseRepository) error GetCounts(byCourseThematic, byLearningType string) int + GetStats(copyMap bool) map[string]LearningTypeStat } diff --git a/internal/kurious/domain/repository.go b/internal/kurious/domain/repository.go index 9cbde3a..abac13b 100644 --- a/internal/kurious/domain/repository.go +++ b/internal/kurious/domain/repository.go @@ -14,6 +14,7 @@ type ListCoursesParams struct { NextPageToken string Limit int + Offset int } type CreateCourseParams struct { @@ -34,10 +35,19 @@ type CreateCourseParams struct { StartsAt time.Time } +type LearningTypeStat struct { + Count int + CourseThematic map[string]int +} + +type ListCoursesStatsResult struct { + StatsByLearningType map[string]LearningTypeStat +} + type ListCoursesResult struct { - Courses []Course - AvailableCoursesOfSub map[string]int - NextPageToken string + Courses []Course + NextPageToken string + Count int } type ListLearningTypeResult struct { diff --git a/internal/kurious/ports/http/bootstrap/core.templ b/internal/kurious/ports/http/bootstrap/core.templ index 0ab4b4a..5e1a95b 100644 --- a/internal/kurious/ports/http/bootstrap/core.templ +++ b/internal/kurious/ports/http/bootstrap/core.templ @@ -81,7 +81,7 @@ templ footer() { } -templ root(page PageKind, s stats) { +templ root(page PageKind, _ stats) { @head(string(page)) diff --git a/internal/kurious/ports/http/bootstrap/core_templ.go b/internal/kurious/ports/http/bootstrap/core_templ.go index 08962c0..a971e1d 100644 --- a/internal/kurious/ports/http/bootstrap/core_templ.go +++ b/internal/kurious/ports/http/bootstrap/core_templ.go @@ -201,7 +201,7 @@ func footer() templ.Component { }) } -func root(page PageKind, s stats) templ.Component { +func root(page PageKind, _ stats) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { diff --git a/internal/kurious/ports/http/bootstrap/list.templ b/internal/kurious/ports/http/bootstrap/list.templ index ed0b885..b9fab49 100644 --- a/internal/kurious/ports/http/bootstrap/list.templ +++ b/internal/kurious/ports/http/bootstrap/list.templ @@ -169,5 +169,6 @@ templ ListCourses(pageType PageKind, s stats, params ListCoursesParams) { @listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams) @listCoursesSectionFilters(params.FilterForm) @listCoursesLearning(params.Categories) + @pagination(params.Pagination) } } diff --git a/internal/kurious/ports/http/bootstrap/list_templ.go b/internal/kurious/ports/http/bootstrap/list_templ.go index e9defe5..e64dbff 100644 --- a/internal/kurious/ports/http/bootstrap/list_templ.go +++ b/internal/kurious/ports/http/bootstrap/list_templ.go @@ -750,6 +750,14 @@ func ListCourses(pageType PageKind, s stats, params ListCoursesParams) templ.Com if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = pagination(params.Pagination).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) } diff --git a/internal/kurious/ports/http/bootstrap/main.templ b/internal/kurious/ports/http/bootstrap/main.templ index 7a6f30e..792c197 100644 --- a/internal/kurious/ports/http/bootstrap/main.templ +++ b/internal/kurious/ports/http/bootstrap/main.templ @@ -3,10 +3,11 @@ package bootstrap import "strconv" type IndexCourseCategoryItem struct { - ID string - Name string - Description string - Count int + ID string + Name string + Description string + ExampleThemes []string + Count int } // courseItemCard is a card that renders a single course thematic item @@ -18,6 +19,16 @@ templ courseItemCard(item IndexCourseCategoryItem) {
{ item.Name }

{ item.Description }

+ if len(item.ExampleThemes) > 0 { +

В данной категории вы можете найти курсы по темам:

+
    + for _, exampleItem := range item.ExampleThemes { +
  • + { exampleItem } +
  • + } +
+ }
+
for _, item := range items {
@@ -45,14 +56,57 @@ templ courseCategory(items []IndexCourseCategoryItem) {
} -type MainPageParams struct{ +type Pagination struct { + Page int + TotalPages int + BaseURL string +} + +templ pagination(p Pagination) { + if p.Page > 0 { +
+ } +} + +type MainPageParams struct { Breadcrumbs BreadcrumbsParams - Categories []IndexCourseCategoryItem + Categories []IndexCourseCategoryItem + Pagination Pagination } templ MainPage(pageType PageKind, s stats, params MainPageParams) { @root(pageType, s) { @listCoursesSectionHeader(params.Breadcrumbs) @courseCategory(params.Categories) + @pagination(params.Pagination) } } diff --git a/internal/kurious/ports/http/bootstrap/main_templ.go b/internal/kurious/ports/http/bootstrap/main_templ.go index 293a53c..d21ed9b 100644 --- a/internal/kurious/ports/http/bootstrap/main_templ.go +++ b/internal/kurious/ports/http/bootstrap/main_templ.go @@ -13,10 +13,11 @@ import "bytes" import "strconv" type IndexCourseCategoryItem struct { - ID string - Name string - Description string - Count int + ID string + Name string + Description string + ExampleThemes []string + Count int } // courseItemCard is a card that renders a single course thematic item @@ -42,7 +43,7 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 17, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 18, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -55,18 +56,60 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 19, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 20, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 templ.SafeURL = templ.URL("/courses/" + item.ID) - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4))) + if len(item.ExampleThemes) > 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var4 := `В данной категории вы можете найти курсы по темам:` + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, exampleItem := range item.ExampleThemes { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(exampleItem) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 26, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -154,9 +197,162 @@ func courseCategory(items []IndexCourseCategoryItem) templ.Component { }) } +type Pagination struct { + Page int + TotalPages int + BaseURL string +} + +func pagination(p Pagination) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if p.Page > 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + type MainPageParams struct { Breadcrumbs BreadcrumbsParams Categories []IndexCourseCategoryItem + Pagination Pagination } func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component { @@ -167,12 +363,12 @@ func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var10 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var22 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() @@ -190,12 +386,20 @@ func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = pagination(params.Pagination).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) } return templ_7745c5c3_Err }) - templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/kurious/ports/http/bootstrap/vars.go b/internal/kurious/ports/http/bootstrap/vars.go index cb52e39..a29030c 100644 --- a/internal/kurious/ports/http/bootstrap/vars.go +++ b/internal/kurious/ports/http/bootstrap/vars.go @@ -96,6 +96,8 @@ type SubcategoryContainer struct { type ListCoursesParams struct { FilterForm FilterFormParams Categories []CategoryContainer + Pagination Pagination + Items int } func GetOrFallback[T comparable](value T, fallback T) T { @@ -105,3 +107,25 @@ func GetOrFallback[T comparable](value T, fallback T) T { } return value } + +type ordered interface { + ~int8 | ~int16 | ~int32 | ~int64 | ~int | + ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uint | + ~float32 | float64 | ~string +} + +func min[T ordered](lhs, rhs T) T { + if lhs < rhs { + return lhs + } + + return rhs +} + +func max[T ordered](lhs, rhs T) T { + if lhs > rhs { + return lhs + } + + return rhs +} diff --git a/internal/kurious/ports/http/course.go b/internal/kurious/ports/http/course.go index 30d21b7..1272f04 100644 --- a/internal/kurious/ports/http/course.go +++ b/internal/kurious/ports/http/course.go @@ -2,11 +2,9 @@ package http import ( "encoding/json" - "fmt" "log/slog" "net/http" "slices" - "strings" "git.loyso.art/frx/kurious/internal/common/xslices" "git.loyso.art/frx/kurious/internal/kurious/app/query" @@ -16,6 +14,7 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) @@ -30,7 +29,7 @@ type courseTemplServer struct { log *slog.Logger } -func makeTemplListCoursesParams(counts map[string]int, in ...domain.Course) bootstrap.ListCoursesParams { +func makeTemplListCoursesParams(counts map[string]domain.LearningTypeStat, in ...domain.Course) bootstrap.ListCoursesParams { coursesBySubcategory := make(map[string][]bootstrap.CourseInfo, len(in)) subcategoriesByCategories := make(map[string]map[string]struct{}, len(in)) categoryByID := make(map[string]bootstrap.CategoryBaseInfo, len(in)) @@ -61,7 +60,7 @@ func makeTemplListCoursesParams(counts map[string]int, in ...domain.Course) boot categoryByID[c.ThematicID] = bootstrap.CategoryBaseInfo{ ID: c.ThematicID, Name: c.Thematic, - Count: counts[c.ThematicID], + Count: counts[c.LearningTypeID].CourseThematic[c.ThematicID], } } }) @@ -91,7 +90,7 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var span trace.Span - ctx, span = webtracer.Start(ctx, "list") + ctx, span = webtracer.Start(ctx, "http_server.list") defer func() { span.End() }() @@ -106,17 +105,26 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) { jsonParams, _ := json.Marshal(pathParams) span.SetAttributes(paramsAttr.String(string(jsonParams))) + var offset int + if pathParams.Page > 0 { + offset = (pathParams.Page - 1) * pathParams.PerPage + } listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{ CourseThematic: pathParams.CourseThematic, LearningType: pathParams.LearningType, Limit: pathParams.PerPage, NextPageToken: pathParams.NextPageToken, + Offset: offset, }) if handleError(ctx, err, w, c.log, "unable to list courses") { return } - params := makeTemplListCoursesParams(listCoursesResult.AvailableCoursesOfSub, listCoursesResult.Courses...) + statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{}) + if handleError(ctx, err, w, c.log, "unable to load stats") { + return + } + params := makeTemplListCoursesParams(statsresult.StatsByLearningType, listCoursesResult.Courses...) learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{}) if handleError(ctx, err, w, c.log, "unable to list learning types") { @@ -156,12 +164,6 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) { }) } - c.log.DebugContext( - ctx, "using bootstrap", - slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)), - slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)), - ) - params = bootstrap.ListCoursesParams{ FilterForm: bootstrap.FilterFormParams{ BreadcrumbsParams: bootstrap.BreadcrumbsParams{ @@ -172,8 +174,22 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) { AvailableCourseThematics: params.FilterForm.AvailableCourseThematics, }, Categories: params.Categories, + Pagination: bootstrap.Pagination{ + Page: pathParams.Page, + TotalPages: listCoursesResult.Count / pathParams.PerPage, + BaseURL: r.URL.Path, + }, } + c.log.DebugContext( + ctx, "params rendered", + slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)), + slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)), + slog.Int("items", len(listCoursesResult.Courses)), + slog.Int("page", params.Pagination.Page), + slog.Int("total_pages", params.Pagination.TotalPages), + ) + slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.CategoryContainer) int { if lhs.Count > rhs.Count { return 1 @@ -191,13 +207,15 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) { if handleError(ctx, err, w, c.log, "unable to render list courses") { return } + + span.SetStatus(codes.Ok, "request completed") } func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var span trace.Span - ctx, span = webtracer.Start(ctx, "index") + ctx, span = webtracer.Start(ctx, "http_server.index") defer func() { span.End() }() @@ -238,13 +256,7 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) { return in.Name }) - namesStr := strings.Join(names, ",") - - category.Description = fmt.Sprintf( - "Here you can find courses"+ - " such as %s", - namesStr, - ) + category.ExampleThemes = names params.Categories = append(params.Categories, category) } @@ -265,4 +277,5 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) { if handleError(ctx, err, w, c.log, "rendeting template") { return } + span.SetStatus(codes.Ok, "request completed") } diff --git a/internal/kurious/ports/http/server.go b/internal/kurious/ports/http/server.go index 605cd1d..810b09c 100644 --- a/internal/kurious/ports/http/server.go +++ b/internal/kurious/ports/http/server.go @@ -10,10 +10,10 @@ import ( "git.loyso.art/frx/kurious/internal/common/errors" "git.loyso.art/frx/kurious/internal/common/xcontext" "git.loyso.art/frx/kurious/internal/kurious/service" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" "github.com/gorilla/mux" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) type Server struct { @@ -38,8 +38,8 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo } span := trace.SpanFromContext(ctx) - span.SetStatus(codes.Error, "error during handling request") span.RecordError(err) + span.SetStatus(codes.Error, "error during handling request") var errorString string var code int @@ -66,10 +66,16 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo type pagination struct { NextPageToken string PerPage int + Page int } func parsePaginationFromQuery(r *http.Request) (out pagination, err error) { query := r.URL.Query() + + if query.Has("next") && query.Has("page") { + return out, errors.NewValidationError("next", `could not be set together with "page"`) + } + out.NextPageToken = query.Get("next") if query.Has("per_page") { @@ -80,6 +86,14 @@ func parsePaginationFromQuery(r *http.Request) (out pagination, err error) { } else { out.PerPage = 50 } + if query.Has("page") { + out.Page, err = strconv.Atoi(query.Get("page")) + if err != nil { + return out, errors.NewValidationError("page", "bad per_page value") + } + } else if !query.Has("next") { + out.Page = 1 + } return out, nil } diff --git a/internal/kurious/service/service.go b/internal/kurious/service/service.go index dc497f2..a75db99 100644 --- a/internal/kurious/service/service.go +++ b/internal/kurious/service/service.go @@ -82,10 +82,11 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.Co InsertOrganization: command.NewCreateOrganizationHandler(organizationrepo, log), }, Queries: app.Queries{ - ListCourses: query.NewListCourseHandler(courseadapter, mapper, log), - ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log), - ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log), - GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log), + ListCourses: query.NewListCourseHandler(courseadapter, mapper, log), + ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log), + ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log), + ListCourseStatistics: query.NewListCoursesStatsHandler(mapper, log), + GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log), ListOrganzations: query.NewListOrganizationsHandler(organizationrepo, log), GetOrganization: query.NewGetOrganizationHandler(organizationrepo, log),