Merge branch 'staging' into 'main'

Staging

See merge request backend/quiz/common!2
This commit is contained in:
Mikhail 2024-03-29 23:45:04 +00:00
commit b423887597
17 changed files with 1318 additions and 46 deletions

@ -9,12 +9,14 @@ import (
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
"github.com/minio/minio-go/v7"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal/sqlcgen"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/account"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/answer"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/question"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/quiz"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/result"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/statistics"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/workers"
"penahub.gitlab.yandexcloud.net/backend/quiz/core.git/clients/auth"
"time"
@ -23,18 +25,19 @@ import (
var errNextDeclined = errors.New("next is declined")
type DAL struct {
conn *sql.DB
authClient *auth.AuthClient
queries *sqlcgen.Queries
AccountRepo *account.AccountRepository
AnswerRepo *answer.AnswerRepository
QuestionRepo *question.QuestionRepository
QuizRepo *quiz.QuizRepository
ResultRepo *result.ResultRepository
WorkerRepo *workers.WorkerRepository
conn *sql.DB
authClient *auth.AuthClient
queries *sqlcgen.Queries
AccountRepo *account.AccountRepository
AnswerRepo *answer.AnswerRepository
QuestionRepo *question.QuestionRepository
QuizRepo *quiz.QuizRepository
ResultRepo *result.ResultRepository
WorkerRepo *workers.WorkerRepository
StatisticsRepo *statistics.StatisticsRepository
}
func New(ctx context.Context, cred string, authClient *auth.AuthClient) (*DAL, error) {
func New(ctx context.Context, cred string, authClient *auth.AuthClient, minioClient *minio.Client) (*DAL, error) {
pool, err := sql.Open("postgres", cred)
if err != nil {
return nil, err
@ -55,9 +58,19 @@ func New(ctx context.Context, cred string, authClient *auth.AuthClient) (*DAL, e
Pool: pool,
})
storerAnswer := &answer.StorerAnswer{}
if minioClient != nil {
storerAnswer, err = answer.NewAnswerMinio(ctx, minioClient)
if err != nil {
return nil, err
}
}
answerRepo := answer.NewAnswerRepository(answer.Deps{
Queries: queries,
Pool: pool,
Queries: queries,
Pool: pool,
AnswerMinio: storerAnswer,
})
questionRepo := question.NewQuestionRepository(question.Deps{
@ -79,16 +92,22 @@ func New(ctx context.Context, cred string, authClient *auth.AuthClient) (*DAL, e
Queries: queries,
})
statisticsRepo := statistics.NewStatisticsRepo(statistics.Deps{
Queries: queries,
Pool: pool,
})
return &DAL{
conn: pool,
authClient: authClient,
queries: queries,
AccountRepo: accountRepo,
AnswerRepo: answerRepo,
QuestionRepo: questionRepo,
QuizRepo: quizRepo,
ResultRepo: resultRepo,
WorkerRepo: workerRepo,
conn: pool,
authClient: authClient,
queries: queries,
AccountRepo: accountRepo,
AnswerRepo: answerRepo,
QuestionRepo: questionRepo,
QuizRepo: quizRepo,
ResultRepo: resultRepo,
WorkerRepo: workerRepo,
StatisticsRepo: statisticsRepo,
}, nil
}

@ -282,8 +282,18 @@ WHERE privilege_name = $2
AND (amount < $3 OR created_at <= NOW() - INTERVAL '1 month');
-- name: GetAllAnswersByQuizID :many
SELECT DISTINCT ON(question_id) content, created_at, question_id, id FROM answer WHERE session = $1 ORDER BY question_id ASC, created_at DESC;
SELECT DISTINCT ON (a.question_id)
a.content, a.created_at, a.question_id, a.id, q.questiontype, quiz.qid
FROM
answer a
JOIN
question q ON a.question_id = q.id
JOIN
quiz ON q.quiz_id = quiz.id
WHERE
a.session = $1 AND a.start = false AND a.deleted = false
ORDER BY
a.question_id ASC, a.created_at DESC;
-- name: InsertAnswers :exec
INSERT INTO answer(
content,
@ -297,12 +307,13 @@ INSERT INTO answer(
device,
os,
browser,
ip
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12);
ip,
start
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13);
-- name: GetResultAnswers :many
SELECT DISTINCT on (question_id) id, content, quiz_id, question_id, fingerprint, session,created_at, result, new,deleted FROM answer WHERE session = (
SELECT session FROM answer WHERE answer.id = $1) ORDER BY question_id, created_at DESC;
SELECT session FROM answer WHERE answer.id = $1) AND start = false ORDER BY question_id, created_at DESC;
-- name: GetQuestions :many
SELECT id, quiz_id, title, description, questiontype, required, deleted, page, content, version, parent_ids, created_at, updated_at FROM question WHERE quiz_id = $1 AND deleted = FALSE;
@ -314,7 +325,308 @@ UPDATE answer SET deleted = TRUE WHERE id = $1 AND deleted = FALSE;
SELECT a.id
FROM answer a
JOIN quiz q ON a.quiz_id = q.id
WHERE a.id = ANY($1::bigint[]) AND a.deleted = FALSE AND q.accountid = $2;
WHERE a.id = ANY($1::bigint[]) AND a.deleted = FALSE AND q.accountid = $2 AND a.start = false;
-- name: CheckResultOwner :one
SELECT q.accountid FROM answer a JOIN quiz q ON a.quiz_id = q.id WHERE a.id = $1 AND a.deleted = FALSE;
SELECT q.accountid FROM answer a JOIN quiz q ON a.quiz_id = q.id WHERE a.id = $1 AND a.deleted = FALSE AND a.start = false;
-- name: DeviceStatistics :many
WITH DeviceStats AS (
SELECT
device_type,
COUNT(*) AS device_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
GROUP BY
device_type
),
OSStats AS (
SELECT
os,
COUNT(*) AS os_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
GROUP BY
os
),
BrowserStats AS (
SELECT
browser,
COUNT(*) AS browser_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
GROUP BY
browser
),
TotalStats AS (
SELECT
COUNT(*) AS total_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
)
SELECT
DeviceStats.device_type,
CAST((DeviceStats.device_count::FLOAT / TotalStats.total_count) * 100.0 AS FLOAT8) AS device_percentage,
OSStats.os,
CAST((OSStats.os_count::FLOAT / TotalStats.total_count) * 100.0 AS FLOAT8) AS os_percentage,
BrowserStats.browser,
CAST((BrowserStats.browser_count::FLOAT / TotalStats.total_count) * 100.0 AS FLOAT8) AS browser_percentage
FROM
DeviceStats,
OSStats,
BrowserStats,
TotalStats;
-- name: GeneralStatistics :many
WITH TimeBucket AS (
SELECT
CASE
WHEN EXTRACT(epoch FROM $2::timestamp) - EXTRACT(epoch FROM $1::timestamp) > 172800 THEN date_trunc('day', timestamp_bucket)
ELSE date_trunc('hour', timestamp_bucket)
END::TIMESTAMP AS time_interval_start,
LEAD(
CASE
WHEN EXTRACT(epoch FROM $2::timestamp) - EXTRACT(epoch FROM $1::timestamp) > 172800 THEN date_trunc('day', timestamp_bucket)
ELSE date_trunc('hour', timestamp_bucket)
END::TIMESTAMP
) OVER (ORDER BY timestamp_bucket) AS time_interval_end
FROM
generate_series($1::timestamp with time zone, $2::timestamp with time zone, '1 hour'::interval) AS timestamp_bucket
),
OpenStats AS (
SELECT
tb.time_interval_start,
tb.time_interval_end,
COUNT(DISTINCT session) AS open_count
FROM
(
SELECT
session,
MIN(created_at) AS first_start_time
FROM
answer
WHERE
answer.quiz_id = $3
AND start = TRUE
AND created_at >= $1::timestamp
AND created_at <= $2::timestamp
GROUP BY
session
) AS first_starts
JOIN TimeBucket tb ON date_trunc('hour', first_starts.first_start_time) >= tb.time_interval_start
AND date_trunc('hour', first_starts.first_start_time) < tb.time_interval_end
GROUP BY
tb.time_interval_start, tb.time_interval_end
),
ResultStats AS (
SELECT
tb.time_interval_start,
tb.time_interval_end,
COUNT(DISTINCT session) AS true_result_count
FROM
(
SELECT
session,
MIN(created_at) AS first_result_time
FROM
answer
WHERE
answer.quiz_id = $3
AND result = TRUE
AND created_at >= $1::timestamp
AND created_at <= $2::timestamp
GROUP BY
session
) AS first_results
JOIN TimeBucket tb ON date_trunc('hour', first_results.first_result_time) >= tb.time_interval_start
AND date_trunc('hour', first_results.first_result_time) < tb.time_interval_end
GROUP BY
tb.time_interval_start, tb.time_interval_end
),
AvTimeStats AS (
SELECT
tb.time_interval_start,
tb.time_interval_end,
AVG(EXTRACT(epoch FROM (a.created_at - b.created_at))) AS avg_time
FROM
answer a
JOIN answer b ON a.session = b.session
JOIN TimeBucket tb ON date_trunc('hour', a.created_at) >= tb.time_interval_start
AND date_trunc('hour', a.created_at) < tb.time_interval_end
WHERE
a.quiz_id = $3
AND a.result = TRUE
AND b.start = TRUE
AND b.quiz_id = $3
AND a.created_at >= $1::timestamp
AND a.created_at <= $2::timestamp
AND b.created_at >= $1::timestamp
AND b.created_at <= $2::timestamp
GROUP BY
tb.time_interval_start, tb.time_interval_end
)
SELECT
tb.time_interval_start AS time_bucket,
COALESCE(os.open_count, 0) AS open_count,
COALESCE(rs.true_result_count, 0) AS true_result_count,
CASE
WHEN COALESCE(os.open_count, 0) > 0 THEN COALESCE(rs.true_result_count, 0) / COALESCE(os.open_count, 0)
ELSE 0
END AS conversion,
COALESCE(at.avg_time, 0) AS avg_time
FROM
TimeBucket tb
LEFT JOIN
OpenStats os ON tb.time_interval_start = os.time_interval_start
AND tb.time_interval_end = os.time_interval_end
LEFT JOIN
ResultStats rs ON tb.time_interval_start = rs.time_interval_start
AND tb.time_interval_end = rs.time_interval_end
LEFT JOIN
AvTimeStats at ON tb.time_interval_start = at.time_interval_start
AND tb.time_interval_end = at.time_interval_end;
-- name: QuestionsStatistics :many
WITH Funnel AS (
SELECT
COUNT(DISTINCT a.session) FILTER (WHERE a.start = FALSE) AS count_start_false,
COUNT(DISTINCT a.session) FILTER (WHERE a.start = TRUE) AS count_start_true,
COUNT(DISTINCT CASE WHEN a.result = FALSE AND qid_true_result IS NOT NULL THEN a.session END) AS count_f_result_with_t_question,
COUNT(DISTINCT a.session) FILTER (WHERE a.result = TRUE) AS count_t_result
FROM
answer a
LEFT JOIN (
SELECT DISTINCT a.session, q.id AS qid_true_result
FROM answer a
JOIN question q ON a.question_id = q.id
WHERE a.result = TRUE
) AS q ON a.session = q.session
WHERE
a.quiz_id = $1
AND a.created_at >= TO_TIMESTAMP($2)
AND a.created_at <= TO_TIMESTAMP($3)
),
Results AS (
SELECT
q.title AS question_title,
COUNT(*) AS total_answers,
CAST(COUNT(*) * 100.0 / NULLIF(SUM(COUNT(*)) FILTER (WHERE a.result = TRUE) OVER (PARTITION BY a.quiz_id), 0) AS FLOAT8) AS percentage
FROM
question q
JOIN answer a ON q.id = a.question_id
WHERE
a.quiz_id = $1
AND a.created_at >= TO_TIMESTAMP($2)
AND a.created_at <= TO_TIMESTAMP($3)
AND a.result = TRUE
GROUP BY
q.title, a.quiz_id, a.result
HAVING
COUNT(*) >= 1
),
Questions AS (
SELECT
q.title AS question_title,
a.content AS answer_content,
CAST(
COUNT(CASE WHEN a.result = FALSE THEN 1 END) * 100.0 / NULLIF(SUM(COUNT(CASE WHEN a.result = FALSE THEN 1 END)) OVER (PARTITION BY q.id), 0) AS FLOAT8
) AS percentage
FROM
question q
JOIN answer a ON q.id = a.question_id
WHERE
a.quiz_id = $1
AND a.created_at >= TO_TIMESTAMP($2)
AND a.created_at <= TO_TIMESTAMP($3)
GROUP BY
q.id, q.title, a.content
HAVING
COUNT(*) >= 1
)
SELECT
Funnel.count_start_false,
Funnel.count_start_true,
Funnel.count_f_result_with_t_question,
Funnel.count_t_result,
Results.question_title AS results_title,
Results.percentage AS results_percentage,
Questions.question_title AS questions_title,
Questions.answer_content AS answer_content,
Questions.percentage AS questions_percentage
FROM
Funnel,
Results,
Questions
WHERE
Questions.percentage >= 1;
-- name: QuizCopyQid :one
INSERT INTO quiz (
accountid, archived, fingerprinting, repeatable, note_prevented, mail_notifications, unique_answers, name, description, config,
status, limit_answers, due_to, time_of_passing, pausable, version, version_comment, parent_ids, questions_count, answers_count, average_time_passing, super, group_id
)
SELECT
$2, archived, fingerprinting, repeatable, note_prevented, mail_notifications, unique_answers, name, description, config,
status, limit_answers, due_to, time_of_passing, pausable, version, version_comment, parent_ids, questions_count, answers_count, average_time_passing, super, group_id
FROM
quiz as q
WHERE
q.qid = $1
RETURNING (select id from quiz where qid = $1),id, qid;
-- name: CopyQuestionQuizID :exec
INSERT INTO question (
quiz_id, title, description, questiontype, required,
page, content, version, parent_ids, created_at, updated_at
)
SELECT
$2, title, description, questiontype, required,
page, content, version, parent_ids, created_at, updated_at
FROM
question
WHERE
question.quiz_id = $1 AND deleted = false;
-- name: GetQidOwner :one
SELECT accountid FROM quiz where qid=$1;
-- name: AllServiceStatistics :one
WITH Registrations AS (
SELECT COUNT(*) AS registration_count
FROM account
WHERE created_at >= to_timestamp($1) AND created_at <= to_timestamp($2)
),
Quizes AS (
SELECT COUNT(*) AS quiz_count
FROM quiz
WHERE deleted = false AND created_at >= to_timestamp($1) AND created_at <= to_timestamp($2)
),
Results AS (
SELECT COUNT(*) AS result_count
FROM answer
WHERE result = true AND created_at >= to_timestamp($1) AND created_at <= to_timestamp($2)
)
SELECT
(SELECT registration_count FROM Registrations) AS registrations,
(SELECT quiz_count FROM Quizes) AS quizes,
(SELECT result_count FROM Results) AS results;

@ -0,0 +1,2 @@
ALTER TABLE answer
DROP COLUMN start;

@ -0,0 +1,2 @@
ALTER TABLE answer
ADD COLUMN start BOOLEAN NOT NULL DEFAULT FALSE;

@ -35,6 +35,7 @@ type Answer struct {
Os string `db:"os" json:"os"`
Browser string `db:"browser" json:"browser"`
Ip string `db:"ip" json:"ip"`
Start bool `db:"start" json:"start"`
}
type Privilege struct {

@ -8,6 +8,7 @@ package sqlcgen
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
@ -58,6 +59,46 @@ func (q *Queries) AccountPagination(ctx context.Context, arg AccountPaginationPa
return items, nil
}
const allServiceStatistics = `-- name: AllServiceStatistics :one
WITH Registrations AS (
SELECT COUNT(*) AS registration_count
FROM account
WHERE created_at >= to_timestamp($1) AND created_at <= to_timestamp($2)
),
Quizes AS (
SELECT COUNT(*) AS quiz_count
FROM quiz
WHERE deleted = false AND created_at >= to_timestamp($1) AND created_at <= to_timestamp($2)
),
Results AS (
SELECT COUNT(*) AS result_count
FROM answer
WHERE result = true AND created_at >= to_timestamp($1) AND created_at <= to_timestamp($2)
)
SELECT
(SELECT registration_count FROM Registrations) AS registrations,
(SELECT quiz_count FROM Quizes) AS quizes,
(SELECT result_count FROM Results) AS results
`
type AllServiceStatisticsParams struct {
ToTimestamp float64 `db:"to_timestamp" json:"to_timestamp"`
ToTimestamp_2 float64 `db:"to_timestamp_2" json:"to_timestamp_2"`
}
type AllServiceStatisticsRow struct {
Registrations int64 `db:"registrations" json:"registrations"`
Quizes int64 `db:"quizes" json:"quizes"`
Results int64 `db:"results" json:"results"`
}
func (q *Queries) AllServiceStatistics(ctx context.Context, arg AllServiceStatisticsParams) (AllServiceStatisticsRow, error) {
row := q.db.QueryRowContext(ctx, allServiceStatistics, arg.ToTimestamp, arg.ToTimestamp_2)
var i AllServiceStatisticsRow
err := row.Scan(&i.Registrations, &i.Quizes, &i.Results)
return i, err
}
const archiveQuiz = `-- name: ArchiveQuiz :exec
UPDATE quiz SET archived = true WHERE id=$1 AND accountId=$2
`
@ -91,7 +132,7 @@ func (q *Queries) CheckAndAddDefault(ctx context.Context, arg CheckAndAddDefault
}
const checkResultOwner = `-- name: CheckResultOwner :one
SELECT q.accountid FROM answer a JOIN quiz q ON a.quiz_id = q.id WHERE a.id = $1 AND a.deleted = FALSE
SELECT q.accountid FROM answer a JOIN quiz q ON a.quiz_id = q.id WHERE a.id = $1 AND a.deleted = FALSE AND a.start = false
`
func (q *Queries) CheckResultOwner(ctx context.Context, id int64) (string, error) {
@ -105,7 +146,7 @@ const checkResultsOwner = `-- name: CheckResultsOwner :many
SELECT a.id
FROM answer a
JOIN quiz q ON a.quiz_id = q.id
WHERE a.id = ANY($1::bigint[]) AND a.deleted = FALSE AND q.accountid = $2
WHERE a.id = ANY($1::bigint[]) AND a.deleted = FALSE AND q.accountid = $2 AND a.start = false
`
type CheckResultsOwnerParams struct {
@ -171,6 +212,30 @@ func (q *Queries) CopyQuestion(ctx context.Context, arg CopyQuestionParams) (Cop
return i, err
}
const copyQuestionQuizID = `-- name: CopyQuestionQuizID :exec
INSERT INTO question (
quiz_id, title, description, questiontype, required,
page, content, version, parent_ids, created_at, updated_at
)
SELECT
$2, title, description, questiontype, required,
page, content, version, parent_ids, created_at, updated_at
FROM
question
WHERE
question.quiz_id = $1 AND deleted = false
`
type CopyQuestionQuizIDParams struct {
QuizID int64 `db:"quiz_id" json:"quiz_id"`
QuizID_2 int64 `db:"quiz_id_2" json:"quiz_id_2"`
}
func (q *Queries) CopyQuestionQuizID(ctx context.Context, arg CopyQuestionQuizIDParams) error {
_, err := q.db.ExecContext(ctx, copyQuestionQuizID, arg.QuizID, arg.QuizID_2)
return err
}
const copyQuiz = `-- name: CopyQuiz :one
INSERT INTO quiz(
accountid, archived,fingerprinting,repeatable,note_prevented,mail_notifications,unique_answers,name,description,config,
@ -345,6 +410,119 @@ func (q *Queries) DeleteQuizByID(ctx context.Context, arg DeleteQuizByIDParams)
return i, err
}
const deviceStatistics = `-- name: DeviceStatistics :many
WITH DeviceStats AS (
SELECT
device_type,
COUNT(*) AS device_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
GROUP BY
device_type
),
OSStats AS (
SELECT
os,
COUNT(*) AS os_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
GROUP BY
os
),
BrowserStats AS (
SELECT
browser,
COUNT(*) AS browser_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
GROUP BY
browser
),
TotalStats AS (
SELECT
COUNT(*) AS total_count
FROM
answer
WHERE
answer.quiz_id = $1
AND created_at >= to_timestamp($2)
AND created_at <= to_timestamp($3)
AND result = TRUE
)
SELECT
DeviceStats.device_type,
CAST((DeviceStats.device_count::FLOAT / TotalStats.total_count) * 100.0 AS FLOAT8) AS device_percentage,
OSStats.os,
CAST((OSStats.os_count::FLOAT / TotalStats.total_count) * 100.0 AS FLOAT8) AS os_percentage,
BrowserStats.browser,
CAST((BrowserStats.browser_count::FLOAT / TotalStats.total_count) * 100.0 AS FLOAT8) AS browser_percentage
FROM
DeviceStats,
OSStats,
BrowserStats,
TotalStats
`
type DeviceStatisticsParams struct {
QuizID int64 `db:"quiz_id" json:"quiz_id"`
ToTimestamp float64 `db:"to_timestamp" json:"to_timestamp"`
ToTimestamp_2 float64 `db:"to_timestamp_2" json:"to_timestamp_2"`
}
type DeviceStatisticsRow struct {
DeviceType string `db:"device_type" json:"device_type"`
DevicePercentage float64 `db:"device_percentage" json:"device_percentage"`
Os string `db:"os" json:"os"`
OsPercentage float64 `db:"os_percentage" json:"os_percentage"`
Browser string `db:"browser" json:"browser"`
BrowserPercentage float64 `db:"browser_percentage" json:"browser_percentage"`
}
func (q *Queries) DeviceStatistics(ctx context.Context, arg DeviceStatisticsParams) ([]DeviceStatisticsRow, error) {
rows, err := q.db.QueryContext(ctx, deviceStatistics, arg.QuizID, arg.ToTimestamp, arg.ToTimestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DeviceStatisticsRow
for rows.Next() {
var i DeviceStatisticsRow
if err := rows.Scan(
&i.DeviceType,
&i.DevicePercentage,
&i.Os,
&i.OsPercentage,
&i.Browser,
&i.BrowserPercentage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const duplicateQuestion = `-- name: DuplicateQuestion :one
INSERT INTO question(
quiz_id, title, description, questiontype, required,
@ -375,6 +553,159 @@ func (q *Queries) DuplicateQuestion(ctx context.Context, id int64) (DuplicateQue
return i, err
}
const generalStatistics = `-- name: GeneralStatistics :many
WITH TimeBucket AS (
SELECT
CASE
WHEN EXTRACT(epoch FROM $2::timestamp) - EXTRACT(epoch FROM $1::timestamp) > 172800 THEN date_trunc('day', timestamp_bucket)
ELSE date_trunc('hour', timestamp_bucket)
END::TIMESTAMP AS time_interval_start,
LEAD(
CASE
WHEN EXTRACT(epoch FROM $2::timestamp) - EXTRACT(epoch FROM $1::timestamp) > 172800 THEN date_trunc('day', timestamp_bucket)
ELSE date_trunc('hour', timestamp_bucket)
END::TIMESTAMP
) OVER (ORDER BY timestamp_bucket) AS time_interval_end
FROM
generate_series($1::timestamp with time zone, $2::timestamp with time zone, '1 hour'::interval) AS timestamp_bucket
),
OpenStats AS (
SELECT
tb.time_interval_start,
tb.time_interval_end,
COUNT(DISTINCT session) AS open_count
FROM
(
SELECT
session,
MIN(created_at) AS first_start_time
FROM
answer
WHERE
answer.quiz_id = $3
AND start = TRUE
AND created_at >= $1::timestamp
AND created_at <= $2::timestamp
GROUP BY
session
) AS first_starts
JOIN TimeBucket tb ON date_trunc('hour', first_starts.first_start_time) >= tb.time_interval_start
AND date_trunc('hour', first_starts.first_start_time) < tb.time_interval_end
GROUP BY
tb.time_interval_start, tb.time_interval_end
),
ResultStats AS (
SELECT
tb.time_interval_start,
tb.time_interval_end,
COUNT(DISTINCT session) AS true_result_count
FROM
(
SELECT
session,
MIN(created_at) AS first_result_time
FROM
answer
WHERE
answer.quiz_id = $3
AND result = TRUE
AND created_at >= $1::timestamp
AND created_at <= $2::timestamp
GROUP BY
session
) AS first_results
JOIN TimeBucket tb ON date_trunc('hour', first_results.first_result_time) >= tb.time_interval_start
AND date_trunc('hour', first_results.first_result_time) < tb.time_interval_end
GROUP BY
tb.time_interval_start, tb.time_interval_end
),
AvTimeStats AS (
SELECT
tb.time_interval_start,
tb.time_interval_end,
AVG(EXTRACT(epoch FROM (a.created_at - b.created_at))) AS avg_time
FROM
answer a
JOIN answer b ON a.session = b.session
JOIN TimeBucket tb ON date_trunc('hour', a.created_at) >= tb.time_interval_start
AND date_trunc('hour', a.created_at) < tb.time_interval_end
WHERE
a.quiz_id = $3
AND a.result = TRUE
AND b.start = TRUE
AND b.quiz_id = $3
AND a.created_at >= $1::timestamp
AND a.created_at <= $2::timestamp
AND b.created_at >= $1::timestamp
AND b.created_at <= $2::timestamp
GROUP BY
tb.time_interval_start, tb.time_interval_end
)
SELECT
tb.time_interval_start AS time_bucket,
COALESCE(os.open_count, 0) AS open_count,
COALESCE(rs.true_result_count, 0) AS true_result_count,
CASE
WHEN COALESCE(os.open_count, 0) > 0 THEN COALESCE(rs.true_result_count, 0) / COALESCE(os.open_count, 0)
ELSE 0
END AS conversion,
COALESCE(at.avg_time, 0) AS avg_time
FROM
TimeBucket tb
LEFT JOIN
OpenStats os ON tb.time_interval_start = os.time_interval_start
AND tb.time_interval_end = os.time_interval_end
LEFT JOIN
ResultStats rs ON tb.time_interval_start = rs.time_interval_start
AND tb.time_interval_end = rs.time_interval_end
LEFT JOIN
AvTimeStats at ON tb.time_interval_start = at.time_interval_start
AND tb.time_interval_end = at.time_interval_end
`
type GeneralStatisticsParams struct {
Column1 time.Time `db:"column_1" json:"column_1"`
Column2 time.Time `db:"column_2" json:"column_2"`
QuizID int64 `db:"quiz_id" json:"quiz_id"`
}
type GeneralStatisticsRow struct {
TimeBucket time.Time `db:"time_bucket" json:"time_bucket"`
OpenCount int64 `db:"open_count" json:"open_count"`
TrueResultCount int64 `db:"true_result_count" json:"true_result_count"`
Conversion int32 `db:"conversion" json:"conversion"`
AvgTime float64 `db:"avg_time" json:"avg_time"`
}
func (q *Queries) GeneralStatistics(ctx context.Context, arg GeneralStatisticsParams) ([]GeneralStatisticsRow, error) {
rows, err := q.db.QueryContext(ctx, generalStatistics, arg.Column1, arg.Column2, arg.QuizID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GeneralStatisticsRow
for rows.Next() {
var i GeneralStatisticsRow
if err := rows.Scan(
&i.TimeBucket,
&i.OpenCount,
&i.TrueResultCount,
&i.Conversion,
&i.AvgTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAccAndPrivilegeByEmail = `-- name: GetAccAndPrivilegeByEmail :one
SELECT
a.id,
@ -476,14 +807,27 @@ func (q *Queries) GetAccountWithPrivileges(ctx context.Context, userID sql.NullS
}
const getAllAnswersByQuizID = `-- name: GetAllAnswersByQuizID :many
SELECT DISTINCT ON(question_id) content, created_at, question_id, id FROM answer WHERE session = $1 ORDER BY question_id ASC, created_at DESC
SELECT DISTINCT ON (a.question_id)
a.content, a.created_at, a.question_id, a.id, q.questiontype, quiz.qid
FROM
answer a
JOIN
question q ON a.question_id = q.id
JOIN
quiz ON q.quiz_id = quiz.id
WHERE
a.session = $1 AND a.start = false AND a.deleted = false
ORDER BY
a.question_id ASC, a.created_at DESC
`
type GetAllAnswersByQuizIDRow struct {
Content sql.NullString `db:"content" json:"content"`
CreatedAt sql.NullTime `db:"created_at" json:"created_at"`
QuestionID int64 `db:"question_id" json:"question_id"`
ID int64 `db:"id" json:"id"`
Content sql.NullString `db:"content" json:"content"`
CreatedAt sql.NullTime `db:"created_at" json:"created_at"`
QuestionID int64 `db:"question_id" json:"question_id"`
ID int64 `db:"id" json:"id"`
Questiontype interface{} `db:"questiontype" json:"questiontype"`
Qid uuid.NullUUID `db:"qid" json:"qid"`
}
func (q *Queries) GetAllAnswersByQuizID(ctx context.Context, session sql.NullString) ([]GetAllAnswersByQuizIDRow, error) {
@ -500,6 +844,8 @@ func (q *Queries) GetAllAnswersByQuizID(ctx context.Context, session sql.NullStr
&i.CreatedAt,
&i.QuestionID,
&i.ID,
&i.Questiontype,
&i.Qid,
); err != nil {
return nil, err
}
@ -698,6 +1044,17 @@ func (q *Queries) GetPrivilegesQuizAccount(ctx context.Context, id int64) ([]Get
return items, nil
}
const getQidOwner = `-- name: GetQidOwner :one
SELECT accountid FROM quiz where qid=$1
`
func (q *Queries) GetQidOwner(ctx context.Context, qid uuid.NullUUID) (string, error) {
row := q.db.QueryRowContext(ctx, getQidOwner, qid)
var accountid string
err := row.Scan(&accountid)
return accountid, err
}
const getQuestionHistory = `-- name: GetQuestionHistory :many
SELECT id, quiz_id, title, description, questiontype, required, deleted, page, content, version, parent_ids, created_at, updated_at FROM question WHERE question.id = $1 OR question.id = ANY(
SELECT unnest(parent_ids) FROM question WHERE id = $1
@ -985,7 +1342,7 @@ func (q *Queries) GetQuizHistory(ctx context.Context, arg GetQuizHistoryParams)
const getResultAnswers = `-- name: GetResultAnswers :many
SELECT DISTINCT on (question_id) id, content, quiz_id, question_id, fingerprint, session,created_at, result, new,deleted FROM answer WHERE session = (
SELECT session FROM answer WHERE answer.id = $1) ORDER BY question_id, created_at DESC
SELECT session FROM answer WHERE answer.id = $1) AND start = false ORDER BY question_id, created_at DESC
`
type GetResultAnswersRow struct {
@ -1048,8 +1405,9 @@ INSERT INTO answer(
device,
os,
browser,
ip
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ip,
start
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
`
type InsertAnswersParams struct {
@ -1065,6 +1423,7 @@ type InsertAnswersParams struct {
Os string `db:"os" json:"os"`
Browser string `db:"browser" json:"browser"`
Ip string `db:"ip" json:"ip"`
Start bool `db:"start" json:"start"`
}
func (q *Queries) InsertAnswers(ctx context.Context, arg InsertAnswersParams) error {
@ -1081,6 +1440,7 @@ func (q *Queries) InsertAnswers(ctx context.Context, arg InsertAnswersParams) er
arg.Os,
arg.Browser,
arg.Ip,
arg.Start,
)
return err
}
@ -1319,6 +1679,165 @@ func (q *Queries) MoveToHistoryQuiz(ctx context.Context, arg MoveToHistoryQuizPa
return i, err
}
const questionsStatistics = `-- name: QuestionsStatistics :many
WITH Funnel AS (
SELECT
COUNT(DISTINCT a.session) FILTER (WHERE a.start = FALSE) AS count_start_false,
COUNT(DISTINCT a.session) FILTER (WHERE a.start = TRUE) AS count_start_true,
COUNT(DISTINCT CASE WHEN a.result = FALSE AND qid_true_result IS NOT NULL THEN a.session END) AS count_f_result_with_t_question,
COUNT(DISTINCT a.session) FILTER (WHERE a.result = TRUE) AS count_t_result
FROM
answer a
LEFT JOIN (
SELECT DISTINCT a.session, q.id AS qid_true_result
FROM answer a
JOIN question q ON a.question_id = q.id
WHERE a.result = TRUE
) AS q ON a.session = q.session
WHERE
a.quiz_id = $1
AND a.created_at >= TO_TIMESTAMP($2)
AND a.created_at <= TO_TIMESTAMP($3)
),
Results AS (
SELECT
q.title AS question_title,
COUNT(*) AS total_answers,
CAST(COUNT(*) * 100.0 / NULLIF(SUM(COUNT(*)) FILTER (WHERE a.result = TRUE) OVER (PARTITION BY a.quiz_id), 0) AS FLOAT8) AS percentage
FROM
question q
JOIN answer a ON q.id = a.question_id
WHERE
a.quiz_id = $1
AND a.created_at >= TO_TIMESTAMP($2)
AND a.created_at <= TO_TIMESTAMP($3)
AND a.result = TRUE
GROUP BY
q.title, a.quiz_id, a.result
HAVING
COUNT(*) >= 1
),
Questions AS (
SELECT
q.title AS question_title,
a.content AS answer_content,
CAST(
COUNT(CASE WHEN a.result = FALSE THEN 1 END) * 100.0 / NULLIF(SUM(COUNT(CASE WHEN a.result = FALSE THEN 1 END)) OVER (PARTITION BY q.id), 0) AS FLOAT8
) AS percentage
FROM
question q
JOIN answer a ON q.id = a.question_id
WHERE
a.quiz_id = $1
AND a.created_at >= TO_TIMESTAMP($2)
AND a.created_at <= TO_TIMESTAMP($3)
GROUP BY
q.id, q.title, a.content
HAVING
COUNT(*) >= 1
)
SELECT
Funnel.count_start_false,
Funnel.count_start_true,
Funnel.count_f_result_with_t_question,
Funnel.count_t_result,
Results.question_title AS results_title,
Results.percentage AS results_percentage,
Questions.question_title AS questions_title,
Questions.answer_content AS answer_content,
Questions.percentage AS questions_percentage
FROM
Funnel,
Results,
Questions
WHERE
Questions.percentage >= 1
`
type QuestionsStatisticsParams struct {
QuizID int64 `db:"quiz_id" json:"quiz_id"`
ToTimestamp float64 `db:"to_timestamp" json:"to_timestamp"`
ToTimestamp_2 float64 `db:"to_timestamp_2" json:"to_timestamp_2"`
}
type QuestionsStatisticsRow struct {
CountStartFalse int64 `db:"count_start_false" json:"count_start_false"`
CountStartTrue int64 `db:"count_start_true" json:"count_start_true"`
CountFResultWithTQuestion int64 `db:"count_f_result_with_t_question" json:"count_f_result_with_t_question"`
CountTResult int64 `db:"count_t_result" json:"count_t_result"`
ResultsTitle string `db:"results_title" json:"results_title"`
ResultsPercentage float64 `db:"results_percentage" json:"results_percentage"`
QuestionsTitle string `db:"questions_title" json:"questions_title"`
AnswerContent sql.NullString `db:"answer_content" json:"answer_content"`
QuestionsPercentage float64 `db:"questions_percentage" json:"questions_percentage"`
}
func (q *Queries) QuestionsStatistics(ctx context.Context, arg QuestionsStatisticsParams) ([]QuestionsStatisticsRow, error) {
rows, err := q.db.QueryContext(ctx, questionsStatistics, arg.QuizID, arg.ToTimestamp, arg.ToTimestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []QuestionsStatisticsRow
for rows.Next() {
var i QuestionsStatisticsRow
if err := rows.Scan(
&i.CountStartFalse,
&i.CountStartTrue,
&i.CountFResultWithTQuestion,
&i.CountTResult,
&i.ResultsTitle,
&i.ResultsPercentage,
&i.QuestionsTitle,
&i.AnswerContent,
&i.QuestionsPercentage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const quizCopyQid = `-- name: QuizCopyQid :one
INSERT INTO quiz (
accountid, archived, fingerprinting, repeatable, note_prevented, mail_notifications, unique_answers, name, description, config,
status, limit_answers, due_to, time_of_passing, pausable, version, version_comment, parent_ids, questions_count, answers_count, average_time_passing, super, group_id
)
SELECT
$2, archived, fingerprinting, repeatable, note_prevented, mail_notifications, unique_answers, name, description, config,
status, limit_answers, due_to, time_of_passing, pausable, version, version_comment, parent_ids, questions_count, answers_count, average_time_passing, super, group_id
FROM
quiz as q
WHERE
q.qid = $1
RETURNING (select id from quiz where qid = $1),id, qid
`
type QuizCopyQidParams struct {
Qid uuid.NullUUID `db:"qid" json:"qid"`
Accountid string `db:"accountid" json:"accountid"`
}
type QuizCopyQidRow struct {
ID int64 `db:"id" json:"id"`
ID_2 int64 `db:"id_2" json:"id_2"`
Qid uuid.NullUUID `db:"qid" json:"qid"`
}
func (q *Queries) QuizCopyQid(ctx context.Context, arg QuizCopyQidParams) (QuizCopyQidRow, error) {
row := q.db.QueryRowContext(ctx, quizCopyQid, arg.Qid, arg.Accountid)
var i QuizCopyQidRow
err := row.Scan(&i.ID, &i.ID_2, &i.Qid)
return i, err
}
const softDeleteResultByID = `-- name: SoftDeleteResultByID :exec
UPDATE answer SET deleted = TRUE WHERE id = $1 AND deleted = FALSE
`

16
go.mod

@ -17,17 +17,29 @@ require (
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.69 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

32
go.sum

@ -17,6 +17,8 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE=
github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@ -31,6 +33,7 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -38,8 +41,15 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@ -49,8 +59,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.69 h1:l8AnsQFyY1xiwa/DaQskY4NXSLA2yrGsW5iD9nRPVS0=
github.com/minio/minio-go/v7 v7.0.69/go.mod h1:XAvOPJQ5Xlzk5o3o/ArO2NMbhSGkimC+bpW/ngRKDmQ=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@ -77,14 +98,23 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -92,6 +122,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240202120244-c4ef330cfe5d h1:gbaDt35HMDqOK84WYmDIlXMI7rstUcRqNttaT6Kx1do=

@ -147,6 +147,12 @@ type ResultContent struct {
Messenger string `json:"messenger"`
Custom map[string]string `json:"customs"`
Start bool `json:"start"`
//IMGContent ImageContent `json:"imagecontent"`
}
type ImageContent struct {
Description string
Image string
}
type ResultAnswer struct {

@ -307,3 +307,19 @@ func (r *AccountRepository) GetAccAndPrivilegeByEmail(ctx context.Context, email
return account, privileges, nil
}
func (r *AccountRepository) GetQidOwner(ctx context.Context, qId string) (string, error) {
qUUID, err := uuid.Parse(qId)
if err != nil {
return "", err
}
qNullUUID := uuid.NullUUID{UUID: qUUID, Valid: true}
userID, err := r.queries.GetQidOwner(ctx, qNullUUID)
if err != nil {
return "", err
}
return userID, nil
}

@ -3,24 +3,28 @@ package answer
import (
"context"
"database/sql"
"fmt"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal/sqlcgen"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model"
)
type Deps struct {
Queries *sqlcgen.Queries
Pool *sql.DB
Queries *sqlcgen.Queries
Pool *sql.DB
AnswerMinio *StorerAnswer
}
type AnswerRepository struct {
queries *sqlcgen.Queries
pool *sql.DB
queries *sqlcgen.Queries
pool *sql.DB
answerMinio *StorerAnswer
}
func NewAnswerRepository(deps Deps) *AnswerRepository {
return &AnswerRepository{
queries: deps.Queries,
pool: deps.Pool,
queries: deps.Queries,
pool: deps.Pool,
answerMinio: deps.AnswerMinio,
}
}
@ -50,6 +54,7 @@ func (r *AnswerRepository) CreateAnswers(ctx context.Context, answers []model.An
Ip: ans.IP,
Browser: ans.Browser,
Os: ans.OS,
Start: ans.Start,
}
err := r.queries.InsertAnswers(ctx, params)
@ -80,6 +85,15 @@ func (r *AnswerRepository) GetAllAnswersByQuizID(ctx context.Context, session st
for _, row := range rows {
if row.Questiontype.(string) == model.TypeFile {
fileURL, err := r.answerMinio.GetAnswerURL(ctx, row.Qid.UUID.String(), row.QuestionID, row.Content.String)
if err != nil {
fmt.Println("GetAnswerURL dal answer minio answer", err)
return nil, err
}
row.Content = sql.NullString{String: fmt.Sprintf("%s|%s", fileURL, row.Content.String), Valid: true}
}
resultAnswer := model.ResultAnswer{
Content: row.Content.String,
CreatedAt: row.CreatedAt.Time,

@ -0,0 +1,44 @@
package answer
import (
"context"
"fmt"
"github.com/minio/minio-go/v7"
"net/url"
"time"
)
const (
bucketAnswers = "squizanswer"
)
type StorerAnswer struct {
client *minio.Client
}
func NewAnswerMinio(ctx context.Context, minioClient *minio.Client) (*StorerAnswer, error) {
if ok, err := minioClient.BucketExists(ctx, bucketAnswers); !ok {
if err := minioClient.MakeBucket(ctx, bucketAnswers, minio.MakeBucketOptions{}); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
return &StorerAnswer{
client: minioClient,
}, nil
}
func (s *StorerAnswer) GetAnswerURL(ctx context.Context, quizID string, questionID int64, filename string) (string, error) {
objectName := fmt.Sprintf("%s/%d/%s", quizID, questionID, filename)
reqParams := make(url.Values)
reqParams.Set("response-content-disposition", "attachment")
url, err := s.client.PresignedGetObject(ctx, bucketAnswers, objectName, time.Hour*1, reqParams)
if err != nil {
return "", err
}
return url.String(), nil
}

@ -578,3 +578,31 @@ func (r *QuizRepository) GetQuizConfig(ctx context.Context, quizID uint64) (mode
return config, row.Accountid, nil
}
func (r *QuizRepository) QuizMove(ctx context.Context, qID, accountID string) (string, error) {
qUUID, err := uuid.Parse(qID)
if err != nil {
return "", err
}
qNullUUID := uuid.NullUUID{UUID: qUUID, Valid: true}
data, err := r.queries.QuizCopyQid(ctx, sqlcgen.QuizCopyQidParams{
Qid: qNullUUID,
Accountid: accountID,
})
if err != nil {
return "", err
}
err = r.queries.CopyQuestionQuizID(ctx, sqlcgen.CopyQuestionQuizIDParams{
QuizID: data.ID,
QuizID_2: data.ID_2,
})
if err != nil {
return "", err
}
return data.Qid.UUID.String(), err
}

@ -0,0 +1,173 @@
package statistics
import (
"context"
"database/sql"
"penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal/sqlcgen"
"time"
)
type Deps struct {
Queries *sqlcgen.Queries
Pool *sql.DB
}
type StatisticsRepository struct {
queries *sqlcgen.Queries
pool *sql.DB
}
func NewStatisticsRepo(deps Deps) *StatisticsRepository {
return &StatisticsRepository{
queries: deps.Queries,
pool: deps.Pool,
}
}
type DeviceStatReq struct {
QuizId int64
From uint64
To uint64
}
type DeviceStatResp struct {
//ключ DeviceType значение процент
Device map[string]float64 // процентное соотношение DeviceType по всем ответам на опроc c res==true
// тоже самое тут только по OS и BROWSER
OS map[string]float64
Browser map[string]float64
}
func (r *StatisticsRepository) GetDeviceStatistics(ctx context.Context, req DeviceStatReq) (DeviceStatResp, error) {
resp := DeviceStatResp{
Device: make(map[string]float64),
OS: make(map[string]float64),
Browser: make(map[string]float64),
}
allStatistics, err := r.queries.DeviceStatistics(ctx, sqlcgen.DeviceStatisticsParams{
QuizID: req.QuizId,
ToTimestamp: float64(req.From),
ToTimestamp_2: float64(req.To),
})
if err != nil {
return resp, err
}
for _, stat := range allStatistics {
resp.Device[stat.DeviceType] = stat.DevicePercentage
resp.OS[stat.Os] = stat.OsPercentage
resp.Browser[stat.Browser] = stat.BrowserPercentage
}
return resp, nil
}
type GeneralStatsResp struct {
Open map[int64]int64 // количество ответов с полем start == true за период от одного пункта разбиения и до другого
Result map[int64]int64 // количество ответов с полем result == true за период от одного пункта разбиения и до другого
AvTime map[int64]uint64 // среднее время между ответом с полем result == true и start == true. в рамках сессии
Conversion map[int64]int32 // Result/Open за период от одного пункта разбиения и до другого
}
func (r *StatisticsRepository) GetGeneralStatistics(ctx context.Context, req DeviceStatReq) (GeneralStatsResp, error) {
resp := GeneralStatsResp{
Open: make(map[int64]int64),
Result: make(map[int64]int64),
AvTime: make(map[int64]uint64),
Conversion: make(map[int64]int32),
}
// todo затестить запрос нужно, когда на один тру ответ приходится один тру старт апдейтнуть запрос
allStatistics, err := r.queries.GeneralStatistics(ctx, sqlcgen.GeneralStatisticsParams{
QuizID: req.QuizId,
Column1: time.Unix(int64(req.From), 0),
Column2: time.Unix(int64(req.To), 0),
})
if err != nil {
return resp, err
}
for _, stat := range allStatistics {
resp.Open[stat.TimeBucket.Unix()] = stat.OpenCount
resp.Result[stat.TimeBucket.Unix()] = stat.TrueResultCount
resp.AvTime[stat.TimeBucket.Unix()] = uint64(stat.AvgTime)
resp.Conversion[stat.TimeBucket.Unix()] = stat.Conversion
}
return resp, nil
}
type QuestionsStatsResp struct {
// PS это / не или а делить а то я спустя пару часов только догнал
//Funnel 3 отдельных метрики
// 0 - количество сессий с любым ответом кроме start == true / количество сессий с ответом start == true
// 1 - количество сессий с result == false, но тип вопроса, на который ответ == result / количество сессий с ответом start == true
// 2 - количество сессий с ответом result == true / количество сессий с ответом start == true
Funnel [3]float64
// ключ - заголовок вопроса найденного по айдишнику вопроса в ответе result == true,
// значение - процент ответов с result == true и таким айдишником вопроса
Results map[string]float64
// ключ - заголовок вопроса, а значение - map, где ключ - вариант ответа на этот вопрос,
// т.е. группировка по полю Контент, а значение - процент таких ответов
Questions map[string]map[string]float64
}
func (r *StatisticsRepository) GetQuestionsStatistics(ctx context.Context, req DeviceStatReq) (QuestionsStatsResp, error) {
resp := QuestionsStatsResp{
Funnel: [3]float64{},
Results: make(map[string]float64),
Questions: make(map[string]map[string]float64),
}
queStatistics, err := r.queries.QuestionsStatistics(ctx, sqlcgen.QuestionsStatisticsParams{
QuizID: req.QuizId,
ToTimestamp: float64(req.From),
ToTimestamp_2: float64(req.To),
})
if err != nil {
return resp, err
}
for _, row := range queStatistics {
if row.CountStartTrue != 0 {
resp.Funnel[0] = float64(row.CountStartFalse) / float64(row.CountStartTrue)
resp.Funnel[1] = float64(row.CountFResultWithTQuestion) / float64(row.CountStartTrue)
resp.Funnel[2] = float64(row.CountTResult) / float64(row.CountStartTrue)
}
resp.Results[row.ResultsTitle] = row.ResultsPercentage
if resp.Questions[row.QuestionsTitle] == nil {
resp.Questions[row.QuestionsTitle] = make(map[string]float64)
}
resp.Questions[row.QuestionsTitle][row.AnswerContent.String] = row.QuestionsPercentage
}
return resp, nil
}
type StatisticResp struct {
// от from до to
Registrations int64 // количество зарегестрированных аккаунтов
Quizes int64 // количество созданных не удаленных квизов
Results int64 // количество ответов с result = true
}
func (r *StatisticsRepository) AllServiceStatistics(ctx context.Context, from, to uint64) (StatisticResp, error) {
allSvcStats, err := r.queries.AllServiceStatistics(ctx, sqlcgen.AllServiceStatisticsParams{
ToTimestamp: float64(from),
ToTimestamp_2: float64(to),
})
if err != nil {
return StatisticResp{}, err
}
resp := StatisticResp{
Registrations: allSvcStats.Registrations,
Quizes: allSvcStats.Quizes,
Results: allSvcStats.Results,
}
return resp, nil
}

@ -16,6 +16,8 @@ packages:
- "./dal/schema/000005_init.down.sql"
- "./dal/schema/000006_init.up.sql"
- "./dal/schema/000006_init.down.sql"
- "./dal/schema/000007_init.up.sql"
- "./dal/schema/000007_init.down.sql"
engine: "postgresql"
emit_json_tags: true
emit_db_tags: true

57
utils/encrypted.go Normal file

@ -0,0 +1,57 @@
package utils
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
)
type Encrypt struct {
pubKey string
privKey string
}
func NewEncrypt(pubKey, privKey string) *Encrypt {
return &Encrypt{pubKey: pubKey, privKey: privKey}
}
func (e *Encrypt) EncryptStr(str string) ([]byte, error) {
block, _ := pem.Decode([]byte(e.pubKey))
if block == nil {
return nil, errors.New("failed to parse PEM block containing the public key")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPubKey, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("failed to parse RSA public key")
}
shifr, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPubKey, []byte(str))
if err != nil {
return nil, err
}
return shifr, nil
}
func (e *Encrypt) DecryptStr(shifr []byte) (string, error) {
block, _ := pem.Decode([]byte(e.privKey))
if block == nil {
return "", errors.New("failed to parse PEM block containing the private key")
}
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", err
}
res, err := rsa.DecryptPKCS1v15(rand.Reader, priv, shifr)
if err != nil {
return "", err
}
return string(res), nil
}

33
utils/excel.go Normal file

@ -0,0 +1,33 @@
package utils
import (
"bytes"
"github.com/tealeg/xlsx"
)
func CreateExcel(headers []string, data map[int]string) (*bytes.Buffer, error) {
file := xlsx.NewFile()
sheet, err := file.AddSheet("sheet1")
if err != nil {
return nil, err
}
headerRow := sheet.AddRow()
for _, header := range headers {
cell := headerRow.AddCell()
cell.Value = header
}
dataRow := sheet.AddRow()
for i := 0; i < len(headers); i++ {
cell := dataRow.AddCell()
cell.Value = data[i]
}
buffer := new(bytes.Buffer)
if err := file.Write(buffer); err != nil {
return nil, err
}
return buffer, nil
}