Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added comment-groups.csv export. #1852

Open
wants to merge 1 commit into
base: edge
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions server/src/routes/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getZinvite, getZidForRid } from "../utils/zinvite";
import { getPca } from "../utils/pca";
import fail from "../utils/fail";
import logger from "../utils/logger";
import { getPidsForGid } from "../utils/participants";

type Formatters<T> = Record<string, (row: T) => string>;
const sep = "\n";
Expand Down Expand Up @@ -296,6 +297,168 @@ export async function sendParticipantVotesSummary(zid: number, res: Response) {
);
}

type CommentGroupStats = {
tid: number;
txt: string;
total_votes: number;
total_agrees: number;
total_disagrees: number;
total_passes: number;
group_stats: Record<
number,
{
votes: number;
agrees: number;
disagrees: number;
passes: number;
}
>;
};

type GroupVoteStats = {
votes: Record<
number,
{
A: number; // agrees
D: number; // disagrees
S: number; // sum of all votes (agrees + disagrees + passes)
}
>;
};

async function sendCommentGroupsSummary(zid: number, res: Response) {
// Get PCA data to identify groups and get groupVotes
const pca = await getPca(zid);
if (!pca?.asPOJO) {
throw new Error("polis_error_no_pca_data");
}

const groupClusters = pca.asPOJO["group-clusters"] as Record<number, object>;
const groupIds = Object.keys(groupClusters).map(Number);
const groupVotes = pca.asPOJO["group-votes"] as Record<
number,
GroupVoteStats
>;

// Load comment texts
const commentRows = (await pgQueryP_readOnly(
"SELECT tid, txt FROM comments WHERE zid = ($1)",
[zid]
)) as { tid: number; txt: string }[];
const commentTexts = new Map(commentRows.map((row) => [row.tid, row.txt]));

// Initialize stats map
const commentStats = new Map<number, CommentGroupStats>();

// Process each group's votes
for (const groupId of groupIds) {
const groupVoteStats = groupVotes[groupId];
if (!groupVoteStats?.votes) continue;

// Process each comment's votes for this group
for (const [tidStr, votes] of Object.entries(groupVoteStats.votes)) {
const tid = parseInt(tidStr);

// Initialize stats for this comment if we haven't seen it before
if (!commentStats.has(tid)) {
const groupStats: Record<
number,
{ votes: number; agrees: number; disagrees: number; passes: number }
> = {};
for (const gid of groupIds) {
groupStats[gid] = { votes: 0, agrees: 0, disagrees: 0, passes: 0 };
}

commentStats.set(tid, {
tid: tid,
txt: commentTexts.get(tid) || "",
total_votes: 0,
total_agrees: 0,
total_disagrees: 0,
total_passes: 0,
group_stats: groupStats,
});
}

// Get the stats object for this comment
const stats = commentStats.get(tid)!;
const groupStats = stats.group_stats[groupId];

// Update group stats
groupStats.agrees = votes.A;
groupStats.disagrees = votes.D;
groupStats.votes = votes.S; // S is the total number of votes
groupStats.passes = votes.S - (votes.A + votes.D); // Calculate passes from the sum
}
}

// Calculate totals for each comment
for (const stats of commentStats.values()) {
stats.total_agrees = Object.values(stats.group_stats).reduce(
(sum, g) => sum + g.agrees,
0
);
stats.total_disagrees = Object.values(stats.group_stats).reduce(
(sum, g) => sum + g.disagrees,
0
);
stats.total_passes = Object.values(stats.group_stats).reduce(
(sum, g) => sum + g.passes,
0
);
stats.total_votes = Object.values(stats.group_stats).reduce(
(sum, g) => sum + g.votes,
0
);
}

// Format and send CSV
res.setHeader("content-type", "text/csv");

// Create headers
const headers = [
"comment-id",
"comment",
"total-votes",
"total-agrees",
"total-disagrees",
"total-passes",
];
for (const groupId of groupIds) {
const groupLetter = String.fromCharCode(97 + groupId); // 97 is 'a' in ASCII
headers.push(
`group-${groupLetter}-votes`,
`group-${groupLetter}-agrees`,
`group-${groupLetter}-disagrees`,
`group-${groupLetter}-passes`
);
}
res.write(headers.join(",") + sep);

// Write data rows
for (const stats of commentStats.values()) {
const row = [
stats.tid,
formatEscapedText(stats.txt),
stats.total_votes,
stats.total_agrees,
stats.total_disagrees,
stats.total_passes,
];
for (const groupId of groupIds) {
const groupStats = stats.group_stats[groupId];
row.push(
groupStats.votes,
groupStats.agrees,
groupStats.disagrees,
groupStats.passes
);
}
res.write(row.join(",") + sep);
}
res.end();
}

export async function handle_GET_reportExport(
req: {
p: { rid: string; report_type: string };
Expand Down Expand Up @@ -329,6 +492,10 @@ export async function handle_GET_reportExport(
await sendParticipantVotesSummary(zid, res);
break;

case "comment-groups.csv":
await sendCommentGroupsSummary(zid, res);
break;

default:
fail(res, 404, "polis_error_data_unknown_report");
break;
Expand Down
60 changes: 1 addition & 59 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import Config from "./config";
import fail from "./utils/fail";
import { PcaCacheItem, getPca, fetchAndCacheLatestPcaData } from "./utils/pca";
import { getZinvite, getZinvites, getZidForRid } from "./utils/zinvite";
import { getBidIndexToPidMapping, getPidsForGid } from "./utils/participants";

import { handle_GET_reportExport } from "./routes/export";

Expand Down Expand Up @@ -1720,27 +1721,6 @@ function initializePolisHelpers() {
// return res.end();
}

function getBidIndexToPidMapping(zid: number, math_tick: number) {
math_tick = math_tick || -1;
return pgQueryP_readOnly(
"select * from math_bidtopid where zid = ($1) and math_env = ($2);",
[zid, Config.mathEnv]
// Argument of type '(rows: string | any[]) => any' is not assignable to parameter of type '(value: unknown) => any'.
// Types of parameters 'rows' and 'value' are incompatible.
// Type 'unknown' is not assignable to type 'string | any[]'.
// Type 'unknown' is not assignable to type 'any[]'.ts(2345)
// @ts-ignore
).then((rows: string | any[]) => {
if (!rows || !rows.length) {
// Could actually be a 404, would require more work to determine that.
return new Error("polis_err_get_pca_results_missing");
} else if (rows[0].data.math_tick <= math_tick) {
return new Error("polis_err_get_pca_results_not_new");
} else {
return rows[0].data;
}
});
}
function handle_GET_bidToPid(
req: { p: { zid: any; math_tick: any } },
res: {
Expand Down Expand Up @@ -10994,44 +10974,6 @@ Thanks for using Polis!
);
}

function getPidsForGid(zid: any, gid: number, math_tick: number) {
return Promise.all([
getPca(zid, math_tick),
getBidIndexToPidMapping(zid, math_tick),
]).then(function (o: ParticipantOption[]) {
if (!o[0] || !o[0].asPOJO) {
return [];
}
o[0] = o[0].asPOJO;
let clusters = o[0]["group-clusters"];
let indexToBid = o[0]["base-clusters"].id; // index to bid
let bidToIndex = [];
for (let i = 0; i < indexToBid.length; i++) {
bidToIndex[indexToBid[i]] = i;
}
let indexToPids = o[1].bidToPid; // actually index to [pid]
let cluster = clusters[gid];
if (!cluster) {
return [];
}
let members = cluster.members; // bids
let pids: any[] = [];
for (var i = 0; i < members.length; i++) {
let bid = members[i];
let index = bidToIndex[bid];
let morePids = indexToPids[index];
Array.prototype.push.apply(pids, morePids);
}
pids = pids.map(function (x) {
return parseInt(x);
});
pids.sort(function (a, b) {
return a - b;
});
return pids;
});
}

function geoCodeWithGoogleApi(locationString: string) {
let googleApiKey = process.env.GOOGLE_API_KEY;
let address = encodeURI(locationString);
Expand Down
64 changes: 64 additions & 0 deletions server/src/utils/participants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getPca } from "./pca";
import { ParticipantOption } from "../d";
import { queryP_readOnly as pgQueryP_readOnly } from "../db/pg-query";
import Config from "../config";

export function getBidIndexToPidMapping(zid: number, math_tick: number) {
math_tick = math_tick || -1;
return pgQueryP_readOnly(
"select * from math_bidtopid where zid = ($1) and math_env = ($2);",
[zid, Config.mathEnv]
// Argument of type '(rows: string | any[]) => any' is not assignable to parameter of type '(value: unknown) => any'.
// Types of parameters 'rows' and 'value' are incompatible.
// Type 'unknown' is not assignable to type 'string | any[]'.
// Type 'unknown' is not assignable to type 'any[]'.ts(2345)
// @ts-ignore
).then((rows: string | any[]) => {
if (!rows || !rows.length) {
// Could actually be a 404, would require more work to determine that.
return new Error("polis_err_get_pca_results_missing");
} else if (rows[0].data.math_tick <= math_tick) {
return new Error("polis_err_get_pca_results_not_new");
} else {
return rows[0].data;
}
});
}

export function getPidsForGid(zid: any, gid: number, math_tick: number) {
return Promise.all([
getPca(zid, math_tick),
getBidIndexToPidMapping(zid, math_tick),
]).then(function (o: ParticipantOption[]) {
if (!o[0] || !o[0].asPOJO) {
return [];
}
o[0] = o[0].asPOJO;
let clusters = o[0]["group-clusters"];
let indexToBid = o[0]["base-clusters"].id; // index to bid
let bidToIndex = [];
for (let i = 0; i < indexToBid.length; i++) {
bidToIndex[indexToBid[i]] = i;
}
let indexToPids = o[1].bidToPid; // actually index to [pid]
let cluster = clusters[gid];
if (!cluster) {
return [];
}
let members = cluster.members; // bids
let pids: any[] = [];
for (var i = 0; i < members.length; i++) {
let bid = members[i];
let index = bidToIndex[bid];
let morePids = indexToPids[index];
Array.prototype.push.apply(pids, morePids);
}
pids = pids.map(function (x) {
return parseInt(x);
});
pids.sort(function (a, b) {
return a - b;
});
return pids;
});
}
Loading