복잡한 사용자 활동 보고서 요구 사항

Discourse에서 매년 임의로 지정한 두 날짜 사이의 모든 사용자 활동 개요 형식의 활동 보고서를 생성하는 방법을 찾고 있습니다. (현재 내장 기능은 사전 설정된 범위만 제공하며, 이는 오늘 날짜를 기준으로만 적용되는 것 같습니다.) 또한, 결과를 Excel 호환 파일로 저장할 수 있기를 원합니다. (현재 이 기능은 개별 사용자 활동 보고서에 대해서만 가능한 것으로 보입니다.)

  • 이메일로 전송되고 이메일로 응답된 게시물도 포함하고 싶습니다. (온라인 포럼을 사용하면서 동일한 게시물을 읽거나 게시하는 경우 이중 계산되지 않도록 해야 합니다.) 현재 보고서는 이메일을 통한 게시물을 제외하고 있는 것 같습니다.

  • 이러한 결과를 특정 사용자 프로필 필드(고유한 회원 번호)에 따라 필터링하고 싶습니다.

  • 지정한 숫자 범위에 속하지 않는 회원 번호를 가진 사용자의 결과는 제외하고 싶습니다.

  • 이상적으로는 해당 기간 동안 사용자별 활동 점수를 생성할 수 있기를 원합니다. 이때 점수는 읽은 게시물 수, 작성한 게시물 수, 좋아요를 받은 게시물 수를 기준으로 가중치가 적용되어야 합니다.

  • 각 활동 변수(읽은 게시물, 작성한 게시물, 좋아요를 받은 게시물)에 대한 가중치(승수)는 관리자가 조정하고 설정할 수 있어야 합니다. 결과는 가장 가까운 5점 단위로 아래로 반올림되어야 하며, 관리자가 설정한 사전 최대 점수에서 절단되어야 합니다.

  • 있으면 좋은 기능으로는 사용자별 주제 카테고리/태그 분석이 있습니다.

  • 이상적으로는 이 보고서가 매년 지정한 시간에 자동으로 생성되어 이메일로 저에게 전송되면 좋겠습니다. (이것이 있다면 완벽한 결과가 될 것입니다.)

이 모든 것이 실현 가능한가요?

이를 구현하려면 새로운 커스텀 플러그인이 필요할까요, 아니면 현재 Data Explorer 플러그인 내에서 복잡한 SQL 쿼리를 통해 가능한가요?

아니면, 비교적 단순한 ‘모두 내보내기’ 옵션을 찾아 나머지는 Excel로 처리하는 것이 현명한 선택일까요?

여기서는 데이터 탐색기(Data explorer)를 사용하는 것이 가장 좋은 접근 방식일 것입니다. 각 작업별로 다른 쿼리를 작성해야 할 수 있습니다(예: 사용자별 분석은 별도의 쿼리가 됩니다).

데이터 탐색기로 할 수 없는 유일한 부분은 '자동 생성 후 이메일로 전송’하는 기능입니다. 이것이 필수 요구사항이라면, 다른 시스템에서 데이터 탐색기 API를 호출하는 방식으로 구현할 수 있을 것입니다.

데이비드, 감사합니다.
SQL 쿼리를 잘 이해해야 할 것 같습니다.

한 쿼리가 다른 쿼리의 필터링된 출력을 입력으로 받을 수 있나요? 또한 이메일 목록 기반 상호작용이 확실히 포함되는 건가요? 이것이 어떤 방식으로 다르게 저장되는 건가요? 표준 활동 보고서에서 어떤 미묘하지만 극복할 수 없는 이유로 제외된 것은 아닌지 걱정되었습니다..

아니요, 쿼리의 일부 섹션을 복사/붙여넣기해야 합니다.

데이터베이스의 posts 테이블에는 via_email 부울 값이 포함되어 있으므로, 이메일을 통해 생성된 게시물을 찾을 수 있습니다 :+1:

그러나 Discourse는 보낸 이메일에 추적자를 포함하지 않으므로, 이메일 알림이 ‘읽음’ 상태인지 여부를 확인할 수 있는 방법은 없습니다.

‘내장된’ 사용자 활동 보고 기능이 어디서든 복사해서 수정할 수 있는 SQL 쿼리를 통해 처리되는 건가요? - 수개월 동안 바퀴를 다시 발명하는 일이 없도록 하기 위해서요.

“일일 참여 사용자” 보고서를 보고 계신 건가요? 이 보고서는 이 로직으로 생성되며, ActiveRecord를 사용하므로(즉, 원시 SQL은 사용하지 않음) 여전히 로직이 유용한 출발점이 될 수 있습니다.

사용자별 활동 보고서를 CSV 형식으로, 특정 날짜 범위(시작일~종료일)에 걸쳐 생성하는 것이 목표입니다. 최종 목표는 온라인 또는 이메일로 수신/읽은 메시지, 온라인 또는 이메일로 게시한 메시지, 그리고 '좋아요’가 있는 메시지에 더 많은 점수를 부여하는 방식에 따라 각 사용자에게 연간(또는 다른 기간) 활동 점수를 부여하는 것입니다. 어떤 보고서를 기반으로 하면 좋을지 고민 중인데, Admin/Users를 클릭했을 때 처음 보이는 보고서가 이미 제가 원하는 기능의 상당 부분을 갖추고 있어 이를 활용하는 것이 좋을까요?

사용자 디렉터리 로직은 다음 링크에서 확인할 수 있습니다: discourse/app/models/directory_item.rb at 1e66e4602f408d7b2d1105b6c228542dce7f4b38 · discourse/discourse · GitHub

음, SQL로 이를 재현하는 건 쉽지 않아 보입니다(적어도 제겐요). 비슷한 예제가 직접 복사해서 쓸 수 있는 수준으로 없다면, 처음부터 해내기 위해 필요한 만큼의 SQL을 동시에 배워야 하니까요.
마지막으로 프로그래밍을 한 건 고등학교 때인데, 그때는 BASIC을 사용한다고 말하는 게 지금만큼 부끄럽지 않았거든요.

네, 이걸 구현하려면 SQL에 대한 상당한 지식이 필요할 것 같아요. 예산이 있으시다면 #marketplace에서 도움을 줄 사람을 찾으실 수 있을 거예요.

고마워요, 예산이 없어요 (포럼에서 기본 호스팅 비용 외에 기부 수입이 거의 없거든요), 하지만 어쨌든 그쪽으로 갈 것 같네요.

@Paul_King

이 쿼리가 도움이 될 수 있습니다.

https://meta.discourse.org/t/daily-weekly-or-total-stats-by-user-over-a-specified-time-range/275167u=grayden_shand

정말 감사합니다! 시도해 보았지만 구문 오류가 발생합니다

PG::SyntaxError: ERROR: syntax error at or near “WITH”
LINE 13: WITH date_range AS (

(해당 스레드를 통해 미리 PM을 보낸 적이 있는데, 그곳의 작성자와 당신이 동일인물이라는 사실을 나중에야 깨달았습니다. 죄송합니다!)

변수에 어떤 값을 사용 중인지 여쭤봐도 될까요?

쿼리를 가져와서 다음과 같은 값을 가진 테스트 사이트에서 실행해 보았습니다:

  • start_date: 2021-07-01
  • end_date: 2021-07-30
  • coverage: all

안녕하세요, 몇 가지를 시도해 봤는데 모두 같은 결과가 나왔어요 - 예를 들어

죄송합니다. 에러를 재현할 수 없습니다.

현재 가지고 계신 쿼리를 여기에 붙여넣어 주시겠습니까?

SELECT 1-- coverage: 'week', 'all', or 'date'
-- [params]
-- date :start_date = 2019-08-27
-- date :end_date = 2019-09-30
-- text :coverage = week

WITH date_range AS (
SELECT date_trunc('day', dd):: date AS "date", EXTRACT(week from date_trunc('day', dd):: date) AS "week"
FROM generate_series
        ( :start_date::timestamp 
        , :end_date::timestamp
        , '1 day'::interval) dd
), likes_given AS (
SELECT u.id, dr.date, dr.week, count(pa.*) AS "likes"
FROM date_range dr
FULL JOIN users u ON (1=1)
LEFT JOIN post_actions pa ON (pa.created_at:: date = dr.date and post_action_type_id=2 AND user_id = u.id)
GROUP BY dr.date, dr.week, u.id
ORDER BY u.id, dr.date
), posts_summary AS (SELECT u.id, u.username, u.created_at, dr.*, count(p.id) - count(t.id) AS replies, count(t.id) AS topics, COALESCE(sum(p.like_count),0) AS likes_received
from date_range dr
FULL OUTER JOIN users u ON (1=1)
LEFT JOIN posts p ON (p.user_id = u.id AND p.created_at::date=dr.date AND p.deleted_at IS NULL)
LEFT JOIN topics t ON (t.user_id = u.id AND t.created_at::date = dr.date AND p.topic_id = t.id AND t.deleted_at IS NULL)
GROUP BY u.id, dr.date, dr.week
ORDER BY u.id, dr.date), 
visits AS (SELECT u.id, dr.*, COALESCE(sum(posts_read),0) AS posts_read, COALESCE(sum(time_read),0) AS time_read, COUNT(uv.*) AS visits
FROM date_range dr
FULL OUTER JOIN users u ON (1=1)
LEFT JOIN user_visits uv ON (uv.user_id = u.id AND visited_at = dr.date)
GROUP BY u.id, dr.date, dr.week
ORDER BY u.id, dr.date
)

SELECT ps.id, ps.username, ps.created_at, CASE
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END  AS period, sum(ps.replies) AS replies, sum(ps.topics) AS topics, sum(ps.likes_received) AS likes_received, sum(lg.likes) AS likes_given, COALESCE(sum(posts_read),0) AS posts_read, COALESCE(sum(time_read),0) AS time_read, SUM(visits) AS visits
FROM posts_summary ps
LEFT JOIN likes_given lg ON (ps.date = lg.date AND ps.id = lg.id)
LEFT JOIN visits v ON (v.id = ps.id AND v.date = ps.date)
GROUP BY ps.id, ps.username, ps.created_at, CASE 
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END  
ORDER BY ps.id, CASE
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END

아, 그렇군요.

시작 부분의 SELECT 1은 쿼리의 일부가 아니며, 바로 이것이 문제를 일으키는 원인입니다. 이는 Data Explorer에서 새 쿼리를 생성할 때 사용되는 플레이스홀더입니다. 이를 제거하면 정상적으로 작동할 것입니다.

-- coverage: 'week', 'all', or 'date'
-- [params]
-- date :start_date = 2019-08-27
-- date :end_date = 2019-09-30
-- text :coverage = week

WITH date_range AS (
SELECT date_trunc('day', dd):: date AS "date", EXTRACT(week from date_trunc('day', dd):: date) AS "week"
FROM generate_series
        ( :start_date::timestamp 
        , :end_date::timestamp
        , '1 day'::interval) dd
), likes_given AS (
SELECT u.id, dr.date, dr.week, count(pa.*) AS "likes"
FROM date_range dr
FULL JOIN users u ON (1=1)
LEFT JOIN post_actions pa ON (pa.created_at:: date = dr.date and post_action_type_id=2 AND user_id = u.id)
GROUP BY dr.date, dr.week, u.id
ORDER BY u.id, dr.date
), posts_summary AS (SELECT u.id, u.username, u.created_at, dr.*, count(p.id) - count(t.id) AS replies, count(t.id) AS topics, COALESCE(sum(p.like_count),0) AS likes_received
from date_range dr
FULL OUTER JOIN users u ON (1=1)
LEFT JOIN posts p ON (p.user_id = u.id AND p.created_at::date=dr.date AND p.deleted_at IS NULL)
LEFT JOIN topics t ON (t.user_id = u.id AND t.created_at::date = dr.date AND p.topic_id = t.id AND t.deleted_at IS NULL)
GROUP BY u.id, dr.date, dr.week
ORDER BY u.id, dr.date), 
visits AS (SELECT u.id, dr.*, COALESCE(sum(posts_read),0) AS posts_read, COALESCE(sum(time_read),0) AS time_read, COUNT(uv.*) AS visits
FROM date_range dr
FULL OUTER JOIN users u ON (1=1)
LEFT JOIN user_visits uv ON (uv.user_id = u.id AND visited_at = dr.date)
GROUP BY u.id, dr.date, dr.week
ORDER BY u.id, dr.date
)

SELECT ps.id, ps.username, ps.created_at, CASE
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END  AS period, sum(ps.replies) AS replies, sum(ps.topics) AS topics, sum(ps.likes_received) AS likes_received, sum(lg.likes) AS likes_given, COALESCE(sum(posts_read),0) AS posts_read, COALESCE(sum(time_read),0) AS time_read, SUM(visits) AS visits
FROM posts_summary ps
LEFT JOIN likes_given lg ON (ps.date = lg.date AND ps.id = lg.id)
LEFT JOIN visits v ON (v.id = ps.id AND v.date = ps.date)
GROUP BY ps.id, ps.username, ps.created_at, CASE 
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END  
ORDER BY ps.id, CASE
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END

@Grayden_Shand 감사합니다.

오류가 사라졌습니다.

조금 더 여쭤봐도 될까요? 이 쿼리가 생성한 집계 값에 이메일로 작성된 게시글과 이메일 목록 모드(email list mode) 사용자의 이메일 답변이 포함됩니까? 포함되지 않는다면 어떻게 포함시킬 수 있을까요?

또한 사용자 이름 옆에 사용자 프로필의 커스텀 필드 값을 표시하려면 어떻게 해야 하나요?

관련 필드 이름을 파악하고 이를 구현하는 방법에 대해 조언해 주실 수 있을까요?

포함되어야 합니다. David가 언급했듯이, posts 테이블에는 via_email이라는 boolean 필드가 있습니다. 현재 쿼리는 이 필드를 무시하고 있으며, 이메일로 작성되었든 아니든 모든 게시글을 집계하고 있습니다.

user_custom_fields라는 이름의 테이블이 있습니다. 특정 사용자 정의 필드를 포함하려면 이 테이블과 조인(join)해야 합니다.

아마도 post_summary 서브쿼리에서 처리할 것입니다.

예를 들어:

...
), posts_summary AS (
SELECT u.id, u.username, u.created_at, dr.*, count(p.id) - count(t.id) AS replies, count(t.id) AS topics, COALESCE(sum(p.like_count),0) AS likes_received, cf.value as LABEL_FOR_CUSTOM_FIELD
from date_range dr
FULL OUTER JOIN users u ON (1=1)
LEFT JOIN posts p ON (p.user_id = u.id AND p.created_at::date=dr.date AND p.deleted_at IS NULL)
LEFT JOIN topics t ON (t.user_id = u.id AND t.created_at::date = dr.date AND p.topic_id = t.id AND t.deleted_at IS NULL)
LEFT JOIN user_custom_fields cf ON (u.id == cf.user_id and cf.name == "YOUR CUSTOM FIELD NAME") 
GROUP BY u.id, dr.date, dr.week
ORDER BY u.id, dr.date
)
...

SELECT 절에 컬럼을 추가하고, user_custom_fields 테이블을 위한 새로운 JOIN 절을 추가했습니다.

"YOUR CUSTOM FIELD NAME"과 LABEL_FOR_CUSTOM_FIELD를 각각 실제 값으로 교체해야 한다는 점에 유의하세요.

그런 다음 최종 쿼리에서 선택하는 컬럼들도 업데이트해야 합니다.

...
SELECT ps.id, ps.username, ps.created_at, ps.LABEL_FOR_CUSTOM_FIELD, CASE
WHEN :coverage::text = 'week' THEN ps.week::text
WHEN :coverage::text = 'all' THEN '-1'
ELSE ps.date::text
END  AS period, sum(ps.replies) AS replies, sum(ps.topics) AS topics, sum(ps.likes_received) AS likes_received, sum(lg.likes) AS likes_given, COALESCE(sum(posts_read),0) AS posts_read, COALESCE(sum(time_read),0) AS time_read, SUM(visits) AS visits
FROM posts_summary ps
..

아마도 제가 접근하는 방식은 이런 식일 것입니다.

좋은 결과 있으시길 바랍니다!