Skip to content

Commit 4c85ad8

Browse files
authored
feat(rankings): add Top Contributors ranking for open source contributions (#29)
Add a first-class "Top Contributors" ranking, on par with the existing rankings (Top Performers, Guiding Stars, etc.), for points earned by contributing to Codam's open source projects. - Add a dedicated `contribution` fixed point type (variable amount, decided per contribution by staff). - Add the `Top Contributors` ranking definition (slug `top_contributors`, title `Top Contributor %login`) fed by the `contribution` fixed type, so it automatically gets season-end snapshots and title awards like the others. - Add an admin form (and assign route) to award contribution points, tagging them with the `contribution` fixed type so they count towards the ranking. - Add dev script: migrate_contribution_scores.ts (tag pre-existing custom contribution scores with the new fixed type).
1 parent 8402e9e commit 4c85ad8

5 files changed

Lines changed: 216 additions & 0 deletions

File tree

src/dev/create_rankings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const createRankings = async function(): Promise<void> {
3030
await createRanking('Top Endeavors', 'Based on points gained through logtime', 'Top Endeavor %login', 16800, ['logtime', 'idle_logout']);
3131
await createRanking('Philanthropists', 'Based on points gained through donating evaluation points to the pool', 'Philanthropist %login', 16800, ['point_donated']);
3232
await createRanking('Community Leaders', 'Based on points gained through organizing events', 'Community Leader %login', 12600, ['event_private', 'event_basic', 'event_intermediate', 'event_advanced']);
33+
await createRanking('Top Contributors', 'Based on points gained through contributing to Codam\'s open source projects', 'Top Contributor %login', 0, ['contribution']);
3334
};
3435

3536
const main = async function(): Promise<void> {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { prisma } from '../handlers/db';
2+
3+
// Keyword used to identify legacy open source contribution scores by their (free-text) reason.
4+
// Before the dedicated 'contribution' fixed point type existed, staff awarded these as custom
5+
// scores (fixed_type_id = null) with reasons like "Contributed to Codam's open source projects (...)".
6+
const CONTRIBUTION_REASON_KEYWORD = 'contributed';
7+
8+
// Tags existing custom contribution scores with the 'contribution' fixed point type so that they
9+
// count towards the Top Contributors ranking. Run this once after deploying the contribution ranking.
10+
const migrateContributionScores = async function(): Promise<void> {
11+
// Make sure the contribution fixed point type exists (created on application startup)
12+
const contributionType = await prisma.codamCoalitionFixedType.findFirst({
13+
where: {
14+
type: 'contribution',
15+
},
16+
});
17+
if (!contributionType) {
18+
throw new Error("The 'contribution' fixed point type does not exist yet. Start the application at least once before running this migration.");
19+
}
20+
21+
const result = await prisma.codamCoalitionScore.updateMany({
22+
where: {
23+
fixed_type_id: null,
24+
reason: {
25+
contains: CONTRIBUTION_REASON_KEYWORD,
26+
mode: 'insensitive',
27+
},
28+
},
29+
data: {
30+
fixed_type_id: 'contribution',
31+
},
32+
});
33+
34+
console.log(`Tagged ${result.count} existing score(s) with the 'contribution' fixed point type.`);
35+
};
36+
37+
migrateContributionScores().then(() => {
38+
console.log('Contribution score migration complete');
39+
process.exit(0);
40+
}).catch((err) => {
41+
console.error(err);
42+
process.exit(1);
43+
});

src/routes/admin/points.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,36 @@ export const setupAdminPointsRoutes = function(app: Express, prisma: PrismaClien
522522
});
523523
}
524524

525+
if (type === 'contribution') {
526+
// Fetch the most recent scores assigned for open source contributions
527+
const recentContributionScores = await prisma.codamCoalitionScore.findMany({
528+
where: {
529+
fixed_type_id: 'contribution',
530+
},
531+
include: {
532+
user: {
533+
include: {
534+
intra_user: true,
535+
},
536+
},
537+
coalition: {
538+
include: {
539+
intra_coalition: true,
540+
},
541+
},
542+
},
543+
orderBy: {
544+
created_at: 'desc',
545+
},
546+
});
547+
548+
return res.render('admin/points/manual/contribution.njk', {
549+
manual_type: type,
550+
fixedPointType,
551+
recentContributionScores,
552+
});
553+
}
554+
525555
if (fs.existsSync(`templates/admin/points/manual/${type}.njk`)) {
526556
return res.render(`admin/points/manual/${type}.njk`, {
527557
manual_type: type,
@@ -817,6 +847,72 @@ export const setupAdminPointsRoutes = function(app: Express, prisma: PrismaClien
817847
}
818848
});
819849

850+
app.post('/admin/points/manual/contribution/assign', async (req, res) => {
851+
try {
852+
const login = req.body.login;
853+
const pointAmount = parseInt(req.body.point_amount);
854+
const reason = req.body.reason;
855+
if (!login || isNaN(pointAmount) || !reason) {
856+
return res.status(400).send('Invalid input');
857+
}
858+
const redirect = `/admin/points/manual/contribution#single-score-form`;
859+
860+
// Fetch the contribution fixed point type so the score counts towards the Top Contributors ranking
861+
const contributionType = await prisma.codamCoalitionFixedType.findFirst({
862+
where: {
863+
type: 'contribution',
864+
},
865+
});
866+
if (!contributionType) {
867+
return res.status(500).send('Contribution point type not found. Please make sure the application has been initialized correctly.');
868+
}
869+
870+
const sessionUser = req.user as ExpressIntraUser;
871+
console.log(`User ${sessionUser.login} is assigning ${pointAmount} contribution points manually to ${login} for reason "${reason}"`);
872+
873+
// Verify the login exists in our database
874+
const user = await prisma.intraUser.findFirst({
875+
where: {
876+
login: login,
877+
},
878+
select: {
879+
id: true,
880+
login: true,
881+
},
882+
});
883+
if (!user) {
884+
console.log(`The login ${login} has not been found in the coalition system`);
885+
return res.render('admin/points/manual/added.njk', {
886+
redirect,
887+
scores: [],
888+
failedScores: [{ login, amount: pointAmount, error: 'Login not found in coalition system' }],
889+
});
890+
}
891+
892+
// Assign the points, tagged with the contribution fixed point type so they count towards the ranking
893+
const score = await createScore(prisma, contributionType, null, user.id, pointAmount, reason);
894+
if (!score) {
895+
console.warn(`Failed to create score for user ${user.login}`);
896+
return res.render('admin/points/manual/added.njk', {
897+
redirect,
898+
scores: [],
899+
failedScores: [{ login, amount: pointAmount, error: 'Failed to create score' }],
900+
});
901+
}
902+
903+
// Display the points assigned
904+
return res.render('admin/points/manual/added.njk', {
905+
redirect,
906+
scores: [score],
907+
failedScores: [],
908+
});
909+
}
910+
catch (err) {
911+
console.error(err);
912+
return res.status(500).send('An error occurred');
913+
}
914+
});
915+
820916
app.post('/admin/points/manual/custom-csv/assign', async (req, res) => {
821917
try {
822918
const csv = req.body.csv;

src/sync/fixed_point_types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export const initCodamCoalitionFixedTypes = async function(): Promise<void> {
8888
type: "ranking_bonus",
8989
desc: "Bonus points awarded hourly during the final week of a season to top ranking users. This value is not used directly, as the actual points awarded depend on the ranking settings.",
9090
points: 0, // not used directly
91+
},
92+
{
93+
type: "contribution",
94+
desc: "Points awarded for contributing to Codam's open source projects (e.g. the coalition system). The amount is decided per contribution by staff.",
95+
points: 0, // variable; the amount is entered manually per contribution
9196
}
9297
];
9398

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{% extends "base.njk" %}
2+
{% set page_title = "Contribution points assigner" %}
3+
4+
{% block content %}
5+
<nav aria-label="breadcrumb">
6+
<ol class="breadcrumb">
7+
<li class="breadcrumb-item"><a href="/admin">Admin</a></li>
8+
<li class="breadcrumb-item"><a href="/admin/points/manual">Points Assigner</a></li>
9+
<li class="breadcrumb-item active" aria-current="page">{{ manual_type }}</li>
10+
</ol>
11+
</nav>
12+
13+
<div class="alert alert-info">
14+
<p>Use this form to award points to students who <b>contributed to Codam's open source projects</b> (for example the coalition system, CodamHero or the internal clustermap). These points count towards the <a href="/rankings/top_contributors">Top Contributors</a> ranking.</p>
15+
<p class="mb-0">The amount of points is decided per contribution, depending on its size and impact.</p>
16+
</div>
17+
18+
<form class="mb-4" action="/admin/points/manual/contribution/assign" method="post" id="single-score-form">
19+
<div class="mb-2">
20+
<label for="login" class="form-label">Login student</label>
21+
<input type="text" id="login" name="login" class="form-control" placeholder="Login" required>
22+
</div>
23+
24+
<div class="mb-2">
25+
<label for="point_amount" class="form-label">Point amount</label>
26+
<input type="number" id="point_amount" name="point_amount" class="form-control" placeholder="Point amount" required value="100" step="10" min="0">
27+
<div class="form-text">The amount of points to award for this contribution.</div>
28+
</div>
29+
30+
<div class="mb-2">
31+
<label for="reason" class="form-label">Reason</label>
32+
<input type="text" id="reason" name="reason" class="form-control" placeholder="Reason" required value="Contributed to Codam's open source projects ()">
33+
<div class="form-text">Tip: mention which project and pull request, e.g. <i>"Contributed to Codam's open source projects (CodamHero #26)"</i>.</div>
34+
</div>
35+
36+
<button type="submit" class="btn btn-primary">Assign contribution points</button>
37+
</form>
38+
39+
<hr class="mt-4">
40+
41+
<div>
42+
<h3 class="mb-3">Recently assigned contribution points</h3>
43+
<table class="table coalition-colored" id="points-added">
44+
<thead>
45+
<tr>
46+
<th>Date</th>
47+
<th>Login</th>
48+
<th>Coalition</th>
49+
<th>Points</th>
50+
<th>Reason</th>
51+
</tr>
52+
</thead>
53+
<tbody>
54+
{% for score in recentContributionScores %}
55+
<tr class="points-added-row" style="background: {{ score.coalition.intra_coalition.color | rgba(0.25) }}">
56+
<td>{{ score.created_at | timeAgo }}</td>
57+
<td><a href="/profile/{{ score.user.intra_user.login }}/">{{ score.user.intra_user.login }}</a></td>
58+
<td>{{ score.coalition.intra_coalition.name | striptags(true) | escape }}</td>
59+
<td>{{ score.amount | thousands }}</td>
60+
<td>{{ score.reason | striptags(true) | escape }}</td>
61+
</tr>
62+
{% endfor %}
63+
{% if recentContributionScores|length == 0 %}
64+
<tr>
65+
<td colspan="5"><i class="text-muted">No contribution points have been assigned yet.</i></td>
66+
</tr>
67+
{% endif %}
68+
</tbody>
69+
</table>
70+
</div>
71+
{% endblock %}

0 commit comments

Comments
 (0)