count courses by school

This commit is contained in:
Aleksandr Trushkin
2024-06-16 23:55:49 +03:00
parent 1a31006b21
commit c0f45d98c2
15 changed files with 505 additions and 146 deletions

View File

@ -15,6 +15,7 @@ var (
dbStatementAttr = attribute.Key("db.statement")
dbOperationAttr = attribute.Key("db.operation")
dbTableAttr = attribute.Key("db.sql.table")
dbArgumentsAttr = attribute.Key("db.arguments")
)
type domainer[T any] interface {

View File

@ -11,10 +11,12 @@ import (
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
@ -188,6 +190,66 @@ func (r *sqliteCourseRepository) ListCourseThematics(
return result, nil
}
func (r *sqliteCourseRepository) ListStatistics(
ctx context.Context,
params domain.ListStatisticsParams,
) (result domain.ListStatisticsResult, err error) {
const queryTemplate = `SELECT learning_type, course_thematic, organization_id, count(id) as count` +
` FROM courses` +
` WHERE 1=1`
query := queryTemplate
args := make([]any, 0, 3)
if params.LearningTypeID != "" {
query += ` AND learning_type = ?`
args = append(args, params.LearningTypeID)
}
if params.CourseThematicID != "" {
query += ` AND course_thematic = ?`
args = append(args, params.CourseThematicID)
}
if params.OrganizaitonID != "" {
query += ` AND organization_id = ?`
args = append(args, params.OrganizaitonID)
}
query += ` GROUP BY learning_type, course_thematic, organization_id`
query += ` ORDER BY count(id) DESC`
ctx, span := dbTracer.Start(
ctx, "list courses.statistics",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
dbArgumentsAttr.StringSlice(argumentsAsStrings(args...)),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "finished")
}
span.End()
}()
var stats []sqliteCourseStatistic
err = r.db.SelectContext(ctx, &stats, query, args...)
if err != nil {
return result, fmt.Errorf("executing query: %w", err)
}
result.LearningTypeStatistics = xslices.Map(stats, asDomainFunc)
return result, nil
}
func (r *sqliteCourseRepository) Get(
ctx context.Context,
id string,
@ -395,6 +457,17 @@ type sqliteCourseDB struct {
DeletedAt sql.NullTime `db:"deleted_at"`
}
type sqliteCourseStatistic struct {
LearningTypeID string `db:"learning_type"`
CourseThematicID string `db:"course_thematic"`
OrganizationID string `db:"organization_id"`
Count int `db:"count"`
}
func (s sqliteCourseStatistic) AsDomain() domain.StatisticUnit {
return domain.StatisticUnit(s)
}
func nullStringAsDomain(s sql.NullString) nullable.Value[string] {
if s.Valid {
return nullable.NewValue(s.String)
@ -482,3 +555,11 @@ func (c *sqliteCourseRepository) mergeAttributes(custom ...attribute.KeyValue) [
return append(outbase, custom...)
}
func argumentsAsStrings(args ...any) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
out = append(out, fmt.Sprintf("%v", arg))
}
return out
}

View File

@ -106,12 +106,28 @@ func (r *sqliteOrganizationRepository) ListStats(
ctx context.Context,
params domain.ListOrganizationsParams,
) (out []domain.OrganizationStat, err error) {
const query = `SELECT o.id as id, o.external_id as external_id, o.name as name, COUNT(c.id) as courses_count` +
` FROM organizations o` +
` INNER JOIN courses c ON o.id = c.organization_id` +
` GROUP BY o.id, o.external_id, o.name` +
` ORDER BY COUNT(c.id) DESC`
const queryTemplate = `WITH cte as (
SELECT learning_type, course_thematic, organization_id, count(id) as courses_count
FROM courses
WHERE 1=1 {whereSuffix}
GROUP BY learning_type, course_thematic, organization_id
) SELECT o.id as id, o.external_id as external_id, o.name as name, cte.courses_count as courses_count
FROM cte
INNER JOIN organizations o ON o.id = cte.organization_id`
whereSuffix := ""
args := make([]any, 0, 3)
if params.LearningTypeID != "" {
whereSuffix += " AND learning_type = ?"
args = append(args, params.LearningTypeID)
}
if params.CourseThematicID != "" {
whereSuffix += " AND course_thematic = ?"
args = append(args, params.CourseThematicID)
}
query := strings.Replace(queryTemplate, "{whereSuffix}", whereSuffix, 1)
ctx, span := dbTracer.Start(
ctx, "list_stats courses",
trace.WithSpanKind(trace.SpanKindClient),
@ -130,7 +146,7 @@ func (r *sqliteOrganizationRepository) ListStats(
}()
var stats []organizationStatDB
err = r.db.SelectContext(ctx, &stats, query)
err = r.db.SelectContext(ctx, &stats, query, args...)
if err != nil {
return nil, fmt.Errorf("executing query: %w", err)
}

View File

@ -2,26 +2,35 @@ package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListCoursesStats struct{}
type ListCoursesStats struct {
LearningTypeID string
CourseThematicsID string
OrganizationID string
}
type ListCoursesStatsResult struct{}
type ListCoursesStatsHandler decorator.QueryHandler[ListCoursesStats, domain.ListCoursesStatsResult]
type listCoursesStatsHandler struct {
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListCoursesStatsHandler(
mapper domain.CourseMapper,
repo domain.CourseRepository,
log *slog.Logger,
) ListCoursesStatsHandler {
h := listCoursesStatsHandler{
repo: repo,
mapper: mapper,
}
@ -32,7 +41,29 @@ func (h listCoursesStatsHandler) Handle(
ctx context.Context,
query ListCoursesStats,
) (out domain.ListCoursesStatsResult, err error) {
stats := h.mapper.GetStats(false)
out.StatsByLearningType = stats
if query.OrganizationID != "" {
statistics, err := h.repo.ListStatistics(ctx, domain.ListStatisticsParams{
LearningTypeID: query.LearningTypeID,
CourseThematicID: query.CourseThematicsID,
OrganizaitonID: query.OrganizationID,
})
if err != nil {
return out, fmt.Errorf("listing statistics: %w", err)
}
out.StatsByLearningType = make(map[string]domain.LearningTypeStat, len(statistics.LearningTypeStatistics))
for _, unit := range statistics.LearningTypeStatistics {
stats, ok := out.StatsByLearningType[unit.LearningTypeID]
stats.Count++
if !ok {
stats.CourseThematic = make(map[string]int)
}
stats.CourseThematic[unit.CourseThematicID]++
out.StatsByLearningType[unit.LearningTypeID] = stats
}
} else {
stats := h.mapper.GetStats(false)
out.StatsByLearningType = stats
}
return out, nil
}

View File

@ -10,7 +10,9 @@ import (
)
type ListOrganizationsStats struct {
IDs []string
LearningTypeID string
CourseThematicID string
IDs []string
}
type ListOrganizationsStatsHandler decorator.QueryHandler[
@ -38,7 +40,9 @@ func (h listOrganizationsStatsHandler) Handle(
query ListOrganizationsStats,
) ([]domain.OrganizationStat, error) {
stats, err := h.repo.ListStats(ctx, domain.ListOrganizationsParams{
IDs: query.IDs,
LearningTypeID: query.LearningTypeID,
CourseThematicID: query.CourseThematicID,
IDs: query.IDs,
})
if err != nil {
return nil, fmt.Errorf("listing stats: %w", err)

View File

@ -18,6 +18,12 @@ type ListCoursesParams struct {
Ascending bool
}
type ListStatisticsParams struct {
LearningTypeID string
CourseThematicID string
OrganizaitonID string
}
type CreateCourseParams struct {
ID string
ExternalID nullable.Value[string]
@ -63,12 +69,24 @@ type ListCourseThematicsResult struct {
CourseThematicIDs []string
}
type StatisticUnit struct {
LearningTypeID string
CourseThematicID string
OrganizationID string
Count int
}
type ListStatisticsResult struct {
LearningTypeStatistics []StatisticUnit
}
//go:generate mockery --name CourseRepository
type CourseRepository interface {
// List courses by specifid parameters.
List(context.Context, ListCoursesParams) (ListCoursesResult, error)
ListLearningTypes(context.Context) (ListLearningTypeResult, error)
ListCourseThematics(context.Context, ListCourseThematicsParams) (ListCourseThematicsResult, error)
ListStatistics(context.Context, ListStatisticsParams) (ListStatisticsResult, error)
// Get course by id.
// Should return ErrNotFound in case course not found.
Get(ctx context.Context, id string) (Course, error)
@ -103,7 +121,9 @@ type CreateOrganizationParams struct {
}
type ListOrganizationsParams struct {
IDs []string
LearningTypeID string
CourseThematicID string
IDs []string
}
//go:generate mockery --name OrganizationRepository

View File

@ -0,0 +1,27 @@
package bootstrap
templ listCoursesByCourseThematic(params ListCoursesParams) {
<div class="container">
<h2>Здесь вы можете найти интересующие вас курсы
по теме { params.FilterForm.ActiveLearningType.Name }:
</h2>
<ul class="list-group">
for _, courseThematic := range params.FilterForm.AvailableCourseThematics {
<li class="list-group-item">
<a href={templ.SafeURL("/courses/" + params.FilterForm.ActiveLearningType.ID + "/" + courseThematic.ID)}>
{courseThematic.Name}
</a>
</li>
}
</ul>
</div>
}
templ ListCourseThematics(pageType PageKind, s stats, params ListCoursesParams) {
@root(pageType, s) {
@listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams)
@listCoursesSectionFilters(params.FilterForm)
@listCoursesByCourseThematic(params)
}
}

View File

@ -0,0 +1,154 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.513
package bootstrap
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func listCoursesByCourseThematic(params ListCoursesParams) 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container\"><h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var2 := `Здесь вы можете найти интересующие вас курсы`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
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_Var3 := `по теме `
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(params.FilterForm.ActiveLearningType.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list_course_thematics.templ`, Line: 5, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var5 := `:`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h2><ul class=\"list-group\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, courseThematic := range params.FilterForm.AvailableCourseThematics {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"list-group-item\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL = templ.SafeURL("/courses/" + params.FilterForm.ActiveLearningType.ID + "/" + courseThematic.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
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_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(courseThematic.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list_course_thematics.templ`, Line: 12, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div>")
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
})
}
func ListCourseThematics(pageType PageKind, s stats, params ListCoursesParams) 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_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var9 := 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)
}
templ_7745c5c3_Err = listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams).Render(ctx, templ_7745c5c3_Buffer)
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 = listCoursesSectionFilters(params.FilterForm).Render(ctx, templ_7745c5c3_Buffer)
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 = listCoursesByCourseThematic(params).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_Var9), templ_7745c5c3_Buffer)
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
})
}

View File

@ -133,7 +133,11 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
return
}
statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{})
statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{
LearningTypeID: pathParams.LearningType,
CourseThematicsID: pathParams.CourseThematic,
OrganizationID: pathParams.School,
})
if handleError(ctx, err, w, c.log, "unable to load stats") {
return
}
@ -178,24 +182,29 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
})
}
organizaions, err := c.app.Queries.ListOrganizationsStats.Handle(ctx, query.ListOrganizationsStats{})
organizaions, err := c.app.Queries.ListOrganizationsStats.Handle(ctx, query.ListOrganizationsStats{
LearningTypeID: pathParams.LearningType,
CourseThematicID: pathParams.CourseThematic,
})
if handleError(ctx, err, w, c.log, "unable to list organizations") {
return
}
slices.SortFunc(organizaions, func(a, b domain.OrganizationStat) int {
if a.CoursesCount > b.CoursesCount {
organizationStatSortFunc := func(lhs, rhs domain.OrganizationStat) int {
if lhs.CoursesCount > rhs.CoursesCount {
return -1
} else if a.CoursesCount < b.CoursesCount {
} else if lhs.CoursesCount < rhs.CoursesCount {
return 1
}
if a.ID > b.ID {
if lhs.ID > rhs.ID {
return 1
}
return -1
})
}
slices.SortFunc(organizaions, organizationStatSortFunc)
schools := xslices.Map(organizaions, func(in domain.OrganizationStat) bootstrap.NameIDPair {
return bootstrap.NameIDPair{
@ -250,7 +259,12 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
})
span.AddEvent("starting to render")
err = bootstrap.ListCourses(bootstrap.PageCourses, stats, params).Render(ctx, w)
if pathParams.CourseThematic == "" {
params.FilterForm.Render = true
err = bootstrap.ListCourseThematics(bootstrap.PageCourses, stats, params).Render(ctx, w)
} else {
err = bootstrap.ListCourses(bootstrap.PageCourses, stats, params).Render(ctx, w)
}
span.AddEvent("render finished")
if handleError(ctx, err, w, c.log, "unable to render list courses") {

View File

@ -55,14 +55,7 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.Co
organizationrepo = sqliteConnection.Organization()
repoCloser = sqliteConnection
case RepositoryEngineYDB:
ydbConnection, err := adapters.NewYDBConnection(ctx, cfg.YDB, log.With(slog.String("db", "ydb")))
if err != nil {
return Application{}, fmt.Errorf("making ydb connection: %w", err)
}
courseadapter = ydbConnection.CourseRepository()
organizationrepo = ydbConnection.Organization()
repoCloser = ydbConnection
return Application{}, errors.New("ydb is no longer supported")
default:
return Application{}, errors.New("unable to decide which db engine to use")
}
@ -85,7 +78,7 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.Co
ListCourses: query.NewListCourseHandler(courseadapter, mapper, log),
ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log),
ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log),
ListCourseStatistics: query.NewListCoursesStatsHandler(mapper, log),
ListCourseStatistics: query.NewListCoursesStatsHandler(mapper, courseadapter, log),
GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log),
ListOrganzations: query.NewListOrganizationsHandler(organizationrepo, log),