agora inbox for pgsql-www@postgresql.org
help / color / mirror / Atom feed[PATCH v2] Introduce contributor badges
21+ messages / 3 participants
[nested] [flat]
* [PATCH v2] Introduce contributor badges
@ 2026-01-05 22:03 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-01-05 22:03 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they a can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable to prevent these entries from showing up on the contributors
page. If a user later contributes more, we can simply set the field to
the desired recognized contributors status.)
Badges are represented by pictures. These are currently stored
externally and will manually be added to the pgweb repo in media/badges/
(or be external URLs). We can move them into the database later if
manual management of them turns out to be too much work.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- The badge image is mailed to contributors@postgresql.org
- The contributors team adds the image to git and updates the filename
stored in the Badge object
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 ++++-
pgweb/contributors/admin.py | 20 +++++-
pgweb/contributors/forms.py | 92 ++++++++++++++++++++++++++
pgweb/contributors/models.py | 48 ++++++++++++--
pgweb/contributors/views.py | 28 +++++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 5 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 29 +++++++-
templates/contributors/badge.html | 45 +++++++++++++
templates/contributors/list.html | 27 ++++----
templates/contributors/people.html | 42 ++++++++++++
templates/contributors/profile.html | 64 ++++++++++++++++++
16 files changed, 402 insertions(+), 30 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index 4f6cfa78..54fd7e3e 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -126,6 +126,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -143,11 +148,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -179,7 +186,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contributor = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -212,6 +221,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index cdb733b9..71024b36 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge
class ContributorAdminForm(forms.ModelForm):
@@ -18,9 +19,24 @@ class ContributorAdmin(admin.ModelAdmin):
autocomplete_fields = ['user', ]
list_display = ('__str__', 'user', 'ctype',)
list_filter = ('ctype',)
- ordering = ('firstname', 'lastname',)
+ ordering = ('ctype', 'lastname', 'firstname')
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..54dbdd23
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,92 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to remove")
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'image', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['remove_holder', 'add_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['remove_holder'].queryset = self.instance.holders
+ else:
+ del self.fields['remove_holder']
+ del self.fields['add_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ model = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ model.holders.add(user)
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ model.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, model),
+ "The following changes were made to {}:\n\n{}".format(model, "\n".join(ops))
+ )
+
+ return model
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 342cddb2..f189a1a3 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,8 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
class ContributorType(models.Model):
@@ -19,22 +22,59 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
- lastname = models.CharField(max_length=100, null=False, blank=False)
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
+ lastname = models.CharField(max_length=100, null=False, blank=False)
email = models.EmailField(null=False, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
+ help_text='Describe what you did in the PostgreSQL community')
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ image = models.CharField(max_length=100, verbose_name='Path to contribution image', null=True, blank=True, help_text="Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url', 'image']
+
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..4f920c38 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,7 @@
+from django.shortcuts import get_object_or_404
from pgweb.util.contexts import render_pgweb
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge
def completelist(request):
@@ -8,3 +9,28 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = list(Contributor.objects.all())
+ badges = list(Badge.objects.filter(approved=True))
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = list(Badge.objects.filter(approved=True, holders=contributor.user))
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index d73f3151..599f4bf7 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index d9b81f75..2bb875f7 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -70,9 +70,12 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index 412b7b61..d4c00a25 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -45,6 +45,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -84,6 +85,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 45db593f..841908ae 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -156,7 +156,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..ab292476 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,30 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <table class="table">
+ <tbody>
+ {%for b in badges %}
+ {%if forloop.counter0|divisibleby:"4" %}
+ {%if not forloop.first%}</tr>{%endif%}
+ <tr>
+ {%endif%}
+ <td align="center">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"><br>
+ {{b}}
+ </a>
+ </td>
+ {%if forloop.last%}</tr>{%endif%}
+ {%endfor%}
+ </tbody>
+ </table>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +137,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..b35d6e3e
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,45 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>{{badge}} <i class="fa fa-code"></i></h1>
+<p><img src="{%if badge.image %}{{badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Contributors</h2>
+<ul>
+ {%for u in badge.holders.all%}
+ {%for c in u.contributor_set.all%}
+ <li><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a></li>
+ {%endfor%}
+ {%endfor%}
+</ul>
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..01659045 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-<p>These are the fine people that make PostgreSQL what it is today!</p>
-
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people that make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..79689ece
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,42 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people that have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+<table class="table">
+ <tbody>
+ {%for b in badges %}
+ {%if forloop.counter0|divisibleby:"4" %}
+ {%if not forloop.first%}</tr>{%endif%}
+ <tr>
+ {%endif%}
+ <td align="center">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"><br>
+ {{b}}
+ </a>
+ </td>
+ {%if forloop.last%}</tr>{%endif%}
+ {%endfor%}
+ </tbody>
+</table>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..c7efaa44
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,64 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <table class="table">
+ <tbody>
+ {%for b in badges %}
+ {%if forloop.counter0|divisibleby:"4" %}
+ {%if not forloop.first%}</tr>{%endif%}
+ <tr>
+ {%endif%}
+ <td align="center">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"><br>
+ {{b}}
+ </a>
+ </td>
+ {%if forloop.last%}</tr>{%endif%}
+ {%endfor%}
+ </tbody>
+ </table>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.51.0
--5MSjHfPoqsxdXc0M--
^ permalink raw reply [nested|flat] 21+ messages in thread
* [PATCH v2] Introduce contributor badges
@ 2026-01-05 22:03 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-01-05 22:03 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they a can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable to prevent these entries from showing up on the contributors
page. If a user later contributes more, we can simply set the field to
the desired recognized contributors status.)
Badges are represented by pictures. These are currently stored
externally and will manually be added to the pgweb repo in media/badges/
(or be external URLs). We can move them into the database later if
manual management of them turns out to be too much work.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- The badge image is mailed to contributors@postgresql.org
- The contributors team adds the image to git and updates the filename
stored in the Badge object
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 ++++-
pgweb/contributors/admin.py | 20 +++++-
pgweb/contributors/forms.py | 92 ++++++++++++++++++++++++++
pgweb/contributors/models.py | 48 ++++++++++++--
pgweb/contributors/views.py | 28 +++++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 5 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 29 +++++++-
templates/contributors/badge.html | 45 +++++++++++++
templates/contributors/list.html | 27 ++++----
templates/contributors/people.html | 42 ++++++++++++
templates/contributors/profile.html | 64 ++++++++++++++++++
16 files changed, 402 insertions(+), 30 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index 4f6cfa78..54fd7e3e 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -126,6 +126,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -143,11 +148,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -179,7 +186,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contributor = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -212,6 +221,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index cdb733b9..71024b36 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge
class ContributorAdminForm(forms.ModelForm):
@@ -18,9 +19,24 @@ class ContributorAdmin(admin.ModelAdmin):
autocomplete_fields = ['user', ]
list_display = ('__str__', 'user', 'ctype',)
list_filter = ('ctype',)
- ordering = ('firstname', 'lastname',)
+ ordering = ('ctype', 'lastname', 'firstname')
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..54dbdd23
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,92 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to remove")
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'image', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['remove_holder', 'add_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['remove_holder'].queryset = self.instance.holders
+ else:
+ del self.fields['remove_holder']
+ del self.fields['add_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ model = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ model.holders.add(user)
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ model.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, model),
+ "The following changes were made to {}:\n\n{}".format(model, "\n".join(ops))
+ )
+
+ return model
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 342cddb2..f189a1a3 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,8 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
class ContributorType(models.Model):
@@ -19,22 +22,59 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
- lastname = models.CharField(max_length=100, null=False, blank=False)
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
+ lastname = models.CharField(max_length=100, null=False, blank=False)
email = models.EmailField(null=False, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
+ help_text='Describe what you did in the PostgreSQL community')
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ image = models.CharField(max_length=100, verbose_name='Path to contribution image', null=True, blank=True, help_text="Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url', 'image']
+
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..4f920c38 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,7 @@
+from django.shortcuts import get_object_or_404
from pgweb.util.contexts import render_pgweb
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge
def completelist(request):
@@ -8,3 +9,28 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = list(Contributor.objects.all())
+ badges = list(Badge.objects.filter(approved=True))
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = list(Badge.objects.filter(approved=True, holders=contributor.user))
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index d73f3151..599f4bf7 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index d9b81f75..2bb875f7 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -70,9 +70,12 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index 412b7b61..d4c00a25 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -45,6 +45,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -84,6 +85,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 45db593f..841908ae 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -156,7 +156,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..ab292476 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,30 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <table class="table">
+ <tbody>
+ {%for b in badges %}
+ {%if forloop.counter0|divisibleby:"4" %}
+ {%if not forloop.first%}</tr>{%endif%}
+ <tr>
+ {%endif%}
+ <td align="center">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"><br>
+ {{b}}
+ </a>
+ </td>
+ {%if forloop.last%}</tr>{%endif%}
+ {%endfor%}
+ </tbody>
+ </table>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +137,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..b35d6e3e
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,45 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>{{badge}} <i class="fa fa-code"></i></h1>
+<p><img src="{%if badge.image %}{{badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Contributors</h2>
+<ul>
+ {%for u in badge.holders.all%}
+ {%for c in u.contributor_set.all%}
+ <li><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a></li>
+ {%endfor%}
+ {%endfor%}
+</ul>
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..01659045 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-<p>These are the fine people that make PostgreSQL what it is today!</p>
-
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people that make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..79689ece
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,42 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people that have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+<table class="table">
+ <tbody>
+ {%for b in badges %}
+ {%if forloop.counter0|divisibleby:"4" %}
+ {%if not forloop.first%}</tr>{%endif%}
+ <tr>
+ {%endif%}
+ <td align="center">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"><br>
+ {{b}}
+ </a>
+ </td>
+ {%if forloop.last%}</tr>{%endif%}
+ {%endfor%}
+ </tbody>
+</table>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..c7efaa44
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,64 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <table class="table">
+ <tbody>
+ {%for b in badges %}
+ {%if forloop.counter0|divisibleby:"4" %}
+ {%if not forloop.first%}</tr>{%endif%}
+ <tr>
+ {%endif%}
+ <td align="center">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"><br>
+ {{b}}
+ </a>
+ </td>
+ {%if forloop.last%}</tr>{%endif%}
+ {%endfor%}
+ </tbody>
+ </table>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.51.0
--5MSjHfPoqsxdXc0M--
^ permalink raw reply [nested|flat] 21+ messages in thread
* Introduce contributor badges
@ 2026-01-19 16:28 Christoph Berg <myon@debian.org>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-01-19 16:28 UTC (permalink / raw)
To: Magnus Hagander <magnus@hagander.net>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; pgsql-www@lists.postgresql.org, PostgreSQL Contributors <contributors@lists.postgresql.org>
Over the holidays, I used the time to implement the badges idea. We've
discussed in the team and we are happy with how it looks like now, and
I'm attaching the version with the changes from the last contributors
team meeting.
The technical description is in the commit message, so I'm not
duplicating that here. It's obviously a larger patch, but since the
badges are new, I'm not sure it's possible to split it up.
To make rollout less disruptive, we could deploy everything, but leave
out the part that creates links to the new parts
(pgweb/util/contexts.py, anything else?). That way we could test
everything, prefill some initial badges and badge holders, and put in
the links once everything is confirmed to work.
I also plan to present the design at the dev meeting in Brussels.
Re: Magnus Hagander
> If they are not based on the recognized contributors, they should probably
> not be part of that in the data model either. But in particular, we
> shouldn't start creating empty contributor profiles before we even *knoiw*
> that it would.
That part works like this in the patch:
- badges are awarded to users (not to contributors)
- users who have at least one badge are offered to create a profile
page (creates the contributors object)
- these users can then fill in the data fields
> So any user without any confirmation can just add themselves as a
> contributor, without even moderation, and publish data? That sounds like a
> *really* bad idea, tbh.
>
> If it's data published by users where we have at one point added them to
> the contributors list and therefore pre-moderated to some point that is
> reasonable. But allowing random people to add records and then edit them
> and then it turns out to be published is going to be a hard no -- we've
> spent many hundreds of hours dealing with that on the wiki for example, and
> we will definitely not open up another venue for that. If it's things that
> not-specifically-approved users can add, then it *must* be moderated.
The moderation part is that people have to convince a badge-issuing
organisation to add their pg.o account as badge holder. This is
possibly a lot of organisations and people, but everyone involved
should be a recognised community member. Also, the worst harm would be
that could publish a random text on a ..../username/ sub-page that is
easily deleted again if we monitor changes. (Afaict any changes to
contributor objects are mailed somewhere. Not sure if that includes
the bio part already, but it could certainly made to be.)
> The latest one that includes that field. Doing that makes the "manage.py
> makemigration" no longer think there are any changes and stop emitting
> anything for that object.
I haven't included the migrations in the patch yet, will do that after
review of the general feature.
> > As for the questions - I think we should absolutely do opt-in, and I do
> > think it makes sense to auto-opt-in the current major contributors since
> > "it's been like that forever and they haven't complained", but listing the
> > others and in a more visible way there are sure to be some who might not
> > want it. In particular, maybe we should make the listing of *email* a
> > separate opt-in/opt-out? That is you can opt-out of being listed
> > completely, or you can say "list me, but not my email"?
> >
> > So there would be a separate opt-in for being listed on the recognized
> > contributor page and for having a public individual contributor
> > profile? And we would only auto opt-in major contributors to the
> > public individual contributor profiles?
>
> When writing that I was not aware that you were intending to have both a
> "recognized contributor" and a "self-claimed contributor". That does change
> it a bit. If it's a self-claimed contributor then it doesn't make much
> sense to opt-out.
The listing is opt-in. If the user never clicks the "create a profile
page" button, their name is not shown. Not even in the list of badge
holders on the badge page.
Once we have this system, the expectation would be that anyone who
we'd want to promote to a recognized contributor would already have a
profile page created. (If not, we should ask them first why.)
> I think Christoph made a good point about this -- once the contributor
> profile requires an account, which it does in order to get published here,
> they can opt out by just changing their email to being empty. It's only for
> contributors that do not have an account connected that *we* need the email
> (so we can reach them), but with the discussed plan those wouldn't be
> listed anyway since a username is needed in order to get listed. So that
> should leave us without needing a separate opt-out.
Contributor.email is currently still "null=False, blank=True", should
probably change that to null=True.
> Per above, if it is a field that any user can directly edit and it goes to
> publication, it must be moderated. That's a hard requirement. Before we
> made the whole "make me an editor" requirement for the wiki, for examples,
> we had upwards of 10,000 new accounts signed up every day just to post spam
> when it hit the peak.
>
> We might need to add more moderators for it, but it needs to be moderated :)
(See above) Even if that's people who have already proven to have
contributed something useful?
> What does it mean that we would get notifications for them? Is "we"
> > the contributors committee?
>
> Right now, there's just the one moderation team and address that gets it
> for all types of objects. But it would probably be relatively easy to split
> that up so there could be multiple moderation-and-notification teams.
That might make sense. Or some of the contributors team members get an
extra job at pgweb moderation...
Christoph
Attachments:
[text/x-diff] v2-0001-Introduce-contributor-badges.patch (0B, ../../aW5bm-4Dk6f-CvCT@msg.df7cb.de/2-v2-0001-Introduce-contributor-badges.patch)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-01-20 11:56 Andrey Borodin <x4mmm@yandex-team.ru>
parent: Christoph Berg <myon@debian.org>
0 siblings, 1 reply; 21+ messages in thread
From: Andrey Borodin @ 2026-01-20 11:56 UTC (permalink / raw)
To: Christoph Berg <myon@debian.org>; +Cc: Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; pgsql-www@lists.postgresql.org, PostgreSQL Contributors <contributors@lists.postgresql.org>
> On 19 Jan 2026, at 21:28, Christoph Berg <myon@debian.org> wrote:
>
> Over the holidays, I used the time to implement the badges idea. We've
> discussed in the team and we are happy with how it looks like now, and
> I'm attaching the version with the changes from the last contributors
> team meeting.
Thanks for working on this! Looks great!
I've setup preview version to test how things will look like. There's some generated sample data.
http://89.169.141.31:8000/community/people/alice/
ogin:moderator pwd:moderator123
orgmanager1 manager123
orgmanager2 manager123
alice alice123 -- Has 3 badges + profile page
bob bob123 -- Has 2 bandges
charlie charlie123 -- 2 badges, without profile
I'm slightly worried that user does not approve their badges.
Also do we plan to add a page where user can claim a badge so org admin can approve their claim?
And I find it a bit oxymoron: "Basic contributor, not recognized"
Best regards, Andrey Borodin.
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-01-20 12:11 Christoph Berg <myon@debian.org>
parent: Andrey Borodin <x4mmm@yandex-team.ru>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-01-20 12:11 UTC (permalink / raw)
To: Andrey Borodin <x4mmm@yandex-team.ru>; +Cc: Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; pgsql-www@lists.postgresql.org, PostgreSQL Contributors <contributors@lists.postgresql.org>
Re: Andrey Borodin
> Thanks for working on this! Looks great!
> I've setup preview version to test how things will look like. There's some generated sample data.
>
> http://89.169.141.31:8000/community/people/alice/
Thanks, that makes review a bit easier for everyone else I guess.
The "people" page there has a problem:
http://89.169.141.31:8000/community/people/
It's not happening here, so I can't say if that's a bug in the patch.
> I'm slightly worried that user does not approve their badges.
The idea is that the badge itself needs approval and then is trusted.
So I think the worst thing that could happen is that someone
legitimately creates a "PostgreSQL Conf Antarctica Speaker", awards it
to you, but you don't want to publicly admit that you've been there.
Anything I'm missing?
> Also do we plan to add a page where user can claim a badge so org admin can approve their claim?
I think the basic workflow is that people contribute, an organisation
creates a badge for it and awards it to these people. For example,
after the PG19 release, we would create a "PostgreSQL 19 Contributor"
badge and award it to everyone listed in the release notes. Similarly
for conference speakers.
Maybe the other way round shouldn't be too easy so badge issuers don't
get flooded with requests. That's why we put the email link there.
Perhaps we need to make sure all approved badges have clear criteria
for badge holders. For example, "Has said something useful on the
mailing list" would be very fuzzy and invite request spam too much.
> And I find it a bit oxymoron: "Basic contributor, not recognized"
That's there to make the distinction to recognized contributors clear.
Better suggestions welcome.
Christoph
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-01-20 12:52 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-01-20 12:52 UTC (permalink / raw)
To: Andrey Borodin <x4mmm@yandex-team.ru>; +Cc: Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; pgsql-www@lists.postgresql.org, PostgreSQL Contributors <contributors@lists.postgresql.org>
Re: To Andrey Borodin
> It's not happening here, so I can't say if that's a bug in the patch.
My mail client recoded the attachment as latin1. I'll replace the "·"
there by · , that's cleaner anyway.
Christoph
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-01-23 10:14 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-01-23 10:14 UTC (permalink / raw)
To: Andrey Borodin <x4mmm@yandex-team.ru>; +Cc: Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; pgsql-www@lists.postgresql.org, PostgreSQL Contributors <contributors@lists.postgresql.org>
Re: To Andrey Borodin
> My mail client recoded the attachment as latin1. I'll replace the "·"
> there by · , that's cleaner anyway.
New version attached, with ^ this change and rebased.
Christoph
Attachments:
[application/gzip] v3-0001-Introduce-contributor-badges.patch.gz (9.5K, ../../aXNKEnrMjM8QRyOX@msg.df7cb.de/2-v3-0001-Introduce-contributor-badges.patch.gz)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-01-23 19:03 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-01-23 19:03 UTC (permalink / raw)
To: Andrey Borodin <x4mmm@yandex-team.ru>; +Cc: Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; pgsql-www@lists.postgresql.org, PostgreSQL Contributors <contributors@lists.postgresql.org>
More polishing done, now including the database migration script.
Christoph
Attachments:
[application/gzip] v4-0001-Introduce-contributor-badges.patch.gz (10.6K, ../../aXPGENnZTVhrnPTp@msg.df7cb.de/2-v4-0001-Introduce-contributor-badges.patch.gz)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
* [PATCH v7 3/5] Introduce contributor badges
@ 2026-04-24 16:16 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:16 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable, and NULL entries will not show up on the contributors page. If
a user later contributes more, we can simply set the field to the
desired recognized contributors status.)
Badges are represented by pictures. These are currently stored
externally and will manually be added to the pgweb repo in media/badges/
(or be external URLs). We can move them into the database later if
manual management of them turns out to be too much work.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- The badge image is mailed to contributors@postgresql.org
- The contributors team adds the image to git and updates the filename
stored in the Badge object
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
media/css/main.css | 22 +++++
pgweb/account/forms.py | 2 +-
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 +++-
pgweb/contributors/admin.py | 18 +++-
pgweb/contributors/forms.py | 92 +++++++++++++++++++
pgweb/contributors/migrations/0001_initial.py | 2 +-
pgweb/contributors/models.py | 50 +++++++++-
pgweb/contributors/views.py | 34 ++++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 5 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 22 ++++-
templates/contributors/badge.html | 49 ++++++++++
templates/contributors/list.html | 29 +++---
templates/contributors/people.html | 36 ++++++++
templates/contributors/profile.html | 57 ++++++++++++
19 files changed, 417 insertions(+), 33 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/media/css/main.css b/media/css/main.css
index 7e9ebf18..2e823553 100644
--- a/media/css/main.css
+++ b/media/css/main.css
@@ -1737,6 +1737,28 @@ table.contributor-table thead tr th {
width: 50%;
}
+.badges-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px;
+}
+.badges-cell {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+.badges-cell img {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+}
+.badges-cell figcaption {
+ text-align: center;
+ padding: 4px;
+}
+
/* News archive */
#pgContentWrap span.newstag {
white-space: nowrap;
diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py
index 1d0d8907..dba18aff 100644
--- a/pgweb/account/forms.py
+++ b/pgweb/account/forms.py
@@ -173,7 +173,7 @@ class UserForm(forms.ModelForm):
class ContributorForm(forms.ModelForm):
class Meta:
model = Contributor
- exclude = ('ctype', 'user', ) # these fields are not user-editable
+ exclude = ('ctype', 'user', ) # these fields are not user-editable
class AddEmailForm(forms.Form):
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index cbc0bbf9..936b2f23 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle, NewsPostingEmbargo
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -124,6 +124,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -141,11 +146,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -177,7 +184,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contrib = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name, email=user.email)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -210,6 +219,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 33d4d05f..71024b36 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge
class ContributorAdminForm(forms.ModelForm):
@@ -22,5 +23,20 @@ class ContributorAdmin(admin.ModelAdmin):
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..54dbdd23
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,92 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to remove")
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'image', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['remove_holder', 'add_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['remove_holder'].queryset = self.instance.holders
+ else:
+ del self.fields['remove_holder']
+ del self.fields['add_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ model = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ model.holders.add(user)
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ model.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, model),
+ "The following changes were made to {}:\n\n{}".format(model, "\n".join(ops))
+ )
+
+ return model
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0001_initial.py b/pgweb/contributors/migrations/0001_initial.py
index c7d1bd17..244e2e49 100644
--- a/pgweb/contributors/migrations/0001_initial.py
+++ b/pgweb/contributors/migrations/0001_initial.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('company', models.CharField(max_length=100, null=True, blank=True)),
('companyurl', models.URLField(max_length=100, null=True, verbose_name='Company URL', blank=True)),
('location', models.CharField(max_length=100, null=True, blank=True)),
- ('contribution', models.TextField(null=True, blank=True, help_text='This description is currently used for major contributors only')),
+ ('contribution', models.TextField(null=True, blank=True, help_text='Describe what you did in the PostgreSQL community')),
],
options={
'ordering': ('lastname', 'firstname'),
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 8f521785..4a0532ba 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,8 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
class ContributorType(models.Model):
@@ -19,22 +22,59 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
lastname = models.CharField(max_length=100, null=False, blank=False)
- email = models.EmailField(null=False, blank=True)
+ email = models.EmailField(null=True, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
- user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
+ help_text='Describe what you did in the PostgreSQL community')
+ user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ image = models.CharField(max_length=100, verbose_name='Path to contribution image', null=True, blank=True, help_text="Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url', 'image']
+ send_notification = True
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..ee0124ac 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,8 @@
+from django.shortcuts import get_object_or_404
+
from pgweb.util.contexts import render_pgweb
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge
def completelist(request):
@@ -8,3 +10,33 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = Contributor.objects.prefetch_related('user')
+ badges = Badge.objects.filter(approved=True)
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ # badge holders are users, but we want to show only users with a contributor object here
+ holders = badge.holders.filter(contributor__isnull=False). \
+ prefetch_related('contributor'). \
+ order_by('contributor__lastname', 'contributor__firstname')
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ 'holders': holders,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = Badge.objects.filter(approved=True, holders=contributor.user)
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index 1609c780..7902e750 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index ce73c951..50eb4acc 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -73,9 +73,12 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index ca493714..dc60740d 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -47,6 +47,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -86,6 +87,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 1d9a2e3c..2c054800 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -160,7 +160,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..4d6fc04a 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,23 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Leave fields blank that you do not wish to make public. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +130,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..16c995ca
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,49 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
+<p><img src="{%if badge.image %}{{badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Badge Holders</h2>
+ {%if holders|length > 50%}
+ {%for u in holders.all %}
+ <nobr><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a> {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+ {%else%}
+ <ul>
+ {%for u in holders.all%}
+ <li><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a></li>
+ {%endfor%}
+ </ul>
+ {%endif%}
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..8377a8e7 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
-<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-
-<p>These are the fine people that make PostgreSQL what it is today!</p>
+<h1>PostgreSQL Recognized Contributors <i class="fa fa-users"></i></h1>
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people who make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people/">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..20950de3
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,36 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people who have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+
+<div class="badges-grid">
+{%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+{%endfor%}
+</div>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..eca38dc2
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,57 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.53.0
--KPM3iI1sUdlvA3ti
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename=v7-0004-Add-dates-on-badgeholders-so-people-can-be-retire.patch
^ permalink raw reply [nested|flat] 21+ messages in thread
* [PATCH v8 3/3] Introduce contributor badges
@ 2026-04-24 16:16 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:16 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable, and NULL entries will not show up on the contributors page. If
a user later contributes more, we can simply set the field to the
desired recognized contributors status.)
Badges are represented by pictures, preferably square and 150x150,
stored in a ImageBinaryField.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status, and a badge
image
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>/
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
community/badge/<number>/image/
-- the badge image
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
media/css/main.css | 22 +++
pgweb/account/forms.py | 2 +-
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 +-
pgweb/contributors/admin.py | 19 +-
pgweb/contributors/forms.py | 106 ++++++++++
pgweb/contributors/migrations/0001_initial.py | 2 +-
..._ctype_alter_contributor_email_and_more.py | 182 ++++++++++++++++++
pgweb/contributors/models.py | 74 ++++++-
pgweb/contributors/views.py | 51 ++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 6 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 22 ++-
templates/base/form_contents.html | 2 +-
templates/contributors/badge.html | 49 +++++
templates/contributors/list.html | 29 +--
templates/contributors/people.html | 36 ++++
templates/contributors/profile.html | 57 ++++++
21 files changed, 657 insertions(+), 34 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/media/css/main.css b/media/css/main.css
index 7e9ebf18..2e823553 100644
--- a/media/css/main.css
+++ b/media/css/main.css
@@ -1737,6 +1737,28 @@ table.contributor-table thead tr th {
width: 50%;
}
+.badges-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px;
+}
+.badges-cell {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+.badges-cell img {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+}
+.badges-cell figcaption {
+ text-align: center;
+ padding: 4px;
+}
+
/* News archive */
#pgContentWrap span.newstag {
white-space: nowrap;
diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py
index 1d0d8907..dba18aff 100644
--- a/pgweb/account/forms.py
+++ b/pgweb/account/forms.py
@@ -173,7 +173,7 @@ class UserForm(forms.ModelForm):
class ContributorForm(forms.ModelForm):
class Meta:
model = Contributor
- exclude = ('ctype', 'user', ) # these fields are not user-editable
+ exclude = ('ctype', 'user', ) # these fields are not user-editable
class AddEmailForm(forms.Form):
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index cbc0bbf9..936b2f23 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle, NewsPostingEmbargo
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -124,6 +124,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -141,11 +146,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -177,7 +184,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contrib = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name, email=user.email)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -210,6 +219,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 33d4d05f..9bedc4cd 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge, Badgeholder
class ContributorAdminForm(forms.ModelForm):
@@ -22,5 +23,21 @@ class ContributorAdmin(admin.ModelAdmin):
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
+admin.site.register(Badgeholder)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..a2bc587f
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,106 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge, Badgeholder
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+from datetime import date
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+ retire_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to retire.")
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Retired badge holders", help_text="Select one or more users to remove. To un-retire a user, add their email address again in the first field.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'imagedata', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['add_holder', 'retire_holder', 'remove_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['retire_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=True)
+ self.fields['remove_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=False)
+ else:
+ del self.fields['add_holder']
+ del self.fields['retire_holder']
+ del self.fields['remove_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ badge = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ holder = Badgeholder.objects.get_or_create(user=user, badge=badge, awarded_by=get_current_user())[0]
+ if holder.date_retired: # un-retire them
+ holder.date_retired = None
+ holder.save()
+ ops.append('Un-retired badge holder {}'.format(user.username))
+ else:
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'retire_holder' in self.cleaned_data and self.cleaned_data['retire_holder']:
+ for toretire in self.cleaned_data['retire_holder']:
+ Badgeholder.objects.filter(user=toretire).update(date_retired=date.today())
+ ops.append('Retired badge holder {}'.format(toretire.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ badge.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, badge),
+ "The following changes were made to {}:\n\n{}".format(badge, "\n".join(ops))
+ )
+
+ return badge
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0001_initial.py b/pgweb/contributors/migrations/0001_initial.py
index c7d1bd17..244e2e49 100644
--- a/pgweb/contributors/migrations/0001_initial.py
+++ b/pgweb/contributors/migrations/0001_initial.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('company', models.CharField(max_length=100, null=True, blank=True)),
('companyurl', models.URLField(max_length=100, null=True, verbose_name='Company URL', blank=True)),
('location', models.CharField(max_length=100, null=True, blank=True)),
- ('contribution', models.TextField(null=True, blank=True, help_text='This description is currently used for major contributors only')),
+ ('contribution', models.TextField(null=True, blank=True, help_text='Describe what you did in the PostgreSQL community')),
],
options={
'ordering': ('lastname', 'firstname'),
diff --git a/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
new file mode 100644
index 00000000..ed6734d5
--- /dev/null
+++ b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
@@ -0,0 +1,182 @@
+# Generated by Django 5.2.16 on 2026-07-29 12:45
+
+import django.db.models.deletion
+import pgweb.util.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("contributors", "0003_make_email_nullable"),
+ ("core", "0007_usersubmissions"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="contributor",
+ name="ctype",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contributors.contributortype",
+ verbose_name="Contributor Type",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="contributor",
+ name="email",
+ field=models.EmailField(blank=True, max_length=254, null=True),
+ ),
+ migrations.AlterField(
+ model_name="contributor",
+ name="user",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.CreateModel(
+ name="Badge",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("approved", models.BooleanField(default=False)),
+ (
+ "badge",
+ models.CharField(
+ help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".',
+ max_length=32,
+ unique=True,
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="What did the people do who contributed here?",
+ null=True,
+ ),
+ ),
+ (
+ "url",
+ models.URLField(
+ blank=True,
+ help_text="URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)",
+ max_length=100,
+ null=True,
+ verbose_name="Contribution URL",
+ ),
+ ),
+ (
+ "imagedata",
+ pgweb.util.fields.ImageBinaryField(
+ blank=True,
+ help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.",
+ max_length=1000000,
+ null=True,
+ verbose_name="Image",
+ ),
+ ),
+ (
+ "contact",
+ models.CharField(
+ blank=True,
+ help_text="Contact address (email, URL, other) for people who want to be added as badge holder",
+ max_length=100,
+ null=True,
+ verbose_name="Contact address",
+ ),
+ ),
+ ("sortorder", models.IntegerField(blank=True, default=100, null=True)),
+ (
+ "org",
+ models.ForeignKey(
+ help_text='If no organisations are listed here, please check the <a href="/account/orglist/">organisation list</a> and contact the organisation manager or <a href="mailto:webmaster@postgresql.org">webmaster@postgresql.org</a> if no managers are listed. If your organisation is not listed, you can <a href="/account/edit/organisations/">create it</a>.',
+ on_delete=django.db.models.deletion.CASCADE,
+ to="core.organisation",
+ verbose_name="Organisation",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("sortorder", "badge"),
+ },
+ ),
+ migrations.CreateModel(
+ name="Badgeholder",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("date_awarded", models.DateField(auto_now_add=True)),
+ ("date_retired", models.DateField(blank=True, null=True)),
+ (
+ "awarded_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="awarded_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "badge",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contributors.badge",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": (
+ "badge__sortorder",
+ "badge",
+ "user__last_name",
+ "user__first_name",
+ ),
+ },
+ ),
+ migrations.AddField(
+ model_name="badge",
+ name="holders",
+ field=models.ManyToManyField(
+ blank=True,
+ through="contributors.Badgeholder",
+ through_fields=("badge", "user"),
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="badgeholder",
+ constraint=models.UniqueConstraint(
+ fields=("user", "badge"), name="unique_user_badge"
+ ),
+ ),
+ ]
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 8f521785..11589d36 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,9 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
+from pgweb.util.fields import ImageBinaryField
class ContributorType(models.Model):
@@ -19,22 +23,82 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
lastname = models.CharField(max_length=100, null=False, blank=False)
- email = models.EmailField(null=False, blank=True)
+ email = models.EmailField(null=True, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
- user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
+ help_text='Describe what you did in the PostgreSQL community')
+ user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ imagedata = ImageBinaryField(max_length=1000000, resolution=(150, 150), auto_scale=True, blank=True, null=True, verbose_name="Image", help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url']
+ send_notification = True
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
+
+
+class Badgeholder(models.Model):
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ badge = models.ForeignKey(Badge, on_delete=models.CASCADE)
+
+ date_awarded = models.DateField(auto_now_add=True)
+ date_retired = models.DateField(null=True, blank=True)
+ awarded_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="awarded_by")
+
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def __str__(self):
+ label = f"{self.badge} {self.user.first_name} {self.user.last_name} ({self.user})"
+ if self.date_retired:
+ label += " (retired)"
+ return label
+
+ class Meta:
+ ordering = ('badge__sortorder', 'badge', 'user__last_name', 'user__first_name')
+ constraints = [
+ models.UniqueConstraint(fields=["user", "badge"], name="unique_user_badge")
+ ]
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..f439671c 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,12 @@
+import hashlib
+
+from django.shortcuts import get_object_or_404
+from django.http import HttpResponse, HttpResponseNotModified, Http404
+
from pgweb.util.contexts import render_pgweb
+from pgweb.util.image import get_image_contenttype_from_bytes
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge, Badgeholder
def completelist(request):
@@ -8,3 +14,46 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = Contributor.objects.prefetch_related('user')
+ badges = Badge.objects.filter(approved=True)
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ # badge holders are users, but we want to show only users with a contributor object here
+ holders = Badgeholder.objects.filter(badge=badge, user__contributor__isnull=False). \
+ prefetch_related('user__contributor'). \
+ order_by('user__contributor__lastname', 'user__contributor__firstname')
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ 'holders': holders,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = Badgeholder.objects.filter(user=contributor.user, badge__approved=True)
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
+
+
+def badge_image(request, badgeid):
+ badge = get_object_or_404(Badge.objects.only('id', 'imagedata'), id=badgeid, approved=True)
+ if not badge.imagedata:
+ raise Http404
+ etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+ if request.headers.get('If-None-Match') == etag:
+ return HttpResponseNotModified()
+ return HttpResponse(
+ bytes(badge.imagedata),
+ content_type=get_image_contenttype_from_bytes(badge.imagedata[:8]),
+ )
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index 1609c780..7902e750 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index ce73c951..8bbdfdf6 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -73,9 +73,13 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+ re_path(r'^community/badge/(\d+)/image/$', pgweb.contributors.views.badge_image),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index ca493714..dc60740d 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -47,6 +47,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -86,6 +87,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 1d9a2e3c..2c054800 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -160,7 +160,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..c6c49e52 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,23 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Leave fields blank that you do not wish to make public. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +130,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/base/form_contents.html b/templates/base/form_contents.html
index 9a00c216..4f9b4263 100644
--- a/templates/base/form_contents.html
+++ b/templates/base/form_contents.html
@@ -1,5 +1,5 @@
{%load pgfilters%}
-<form class="form-horizontal" method="post" action=".">{%if not nocsrf%}{% csrf_token %}{%endif%}
+<form class="form-horizontal" method="post" action="."{% if form.is_multipart %} enctype="multipart/form-data"{% endif %}>{%if not nocsrf%}{% csrf_token %}{%endif%}
{%if form.errors %}
<div class="alert alert-danger">
Please correct the errors below, and re-submit the form.
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..0e2cee40
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,49 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
+<p><img src="{%if badge.imagedata %}/community/badge/{{badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Badge Holders</h2>
+ {%if holders|length > 50%}
+ {%for u in holders.all %}
+ <nobr>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%}</em>{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+ {%else%}
+ <ul>
+ {%for u in holders.all%}
+ <li>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%} (retired from this activity)</em>{%endif%}</li>
+ {%endfor%}
+ </ul>
+ {%endif%}
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..8377a8e7 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
-<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-
-<p>These are the fine people that make PostgreSQL what it is today!</p>
+<h1>PostgreSQL Recognized Contributors <i class="fa fa-users"></i></h1>
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people who make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people/">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..ce192cd7
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,36 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people who have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+
+<div class="badges-grid">
+{%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+{%endfor%}
+</div>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..fc2eaabc
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,57 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.badge.id}}/">
+ <img src="{%if b.badge.imagedata %}/community/badge/{{b.badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b.badge}}{%if b.date_retired%}<br><em>(retired)</em>{%endif%}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.53.0
--KPM3iI1sUdlvA3ti--
^ permalink raw reply [nested|flat] 21+ messages in thread
* [PATCH v8 3/3] Introduce contributor badges
@ 2026-04-24 16:16 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:16 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable, and NULL entries will not show up on the contributors page. If
a user later contributes more, we can simply set the field to the
desired recognized contributors status.)
Badges are represented by pictures, preferably square and 150x150,
stored in a ImageBinaryField.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status, and a badge
image
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>/
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
community/badge/<number>/image/
-- the badge image
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
media/css/main.css | 22 +++
pgweb/account/forms.py | 2 +-
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 +-
pgweb/contributors/admin.py | 19 +-
pgweb/contributors/forms.py | 106 ++++++++++
pgweb/contributors/migrations/0001_initial.py | 2 +-
..._ctype_alter_contributor_email_and_more.py | 182 ++++++++++++++++++
pgweb/contributors/models.py | 74 ++++++-
pgweb/contributors/views.py | 51 ++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 6 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 22 ++-
templates/base/form_contents.html | 2 +-
templates/contributors/badge.html | 49 +++++
templates/contributors/list.html | 29 +--
templates/contributors/people.html | 36 ++++
templates/contributors/profile.html | 57 ++++++
21 files changed, 657 insertions(+), 34 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/media/css/main.css b/media/css/main.css
index 7e9ebf18..2e823553 100644
--- a/media/css/main.css
+++ b/media/css/main.css
@@ -1737,6 +1737,28 @@ table.contributor-table thead tr th {
width: 50%;
}
+.badges-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px;
+}
+.badges-cell {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+.badges-cell img {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+}
+.badges-cell figcaption {
+ text-align: center;
+ padding: 4px;
+}
+
/* News archive */
#pgContentWrap span.newstag {
white-space: nowrap;
diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py
index 1d0d8907..dba18aff 100644
--- a/pgweb/account/forms.py
+++ b/pgweb/account/forms.py
@@ -173,7 +173,7 @@ class UserForm(forms.ModelForm):
class ContributorForm(forms.ModelForm):
class Meta:
model = Contributor
- exclude = ('ctype', 'user', ) # these fields are not user-editable
+ exclude = ('ctype', 'user', ) # these fields are not user-editable
class AddEmailForm(forms.Form):
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index cbc0bbf9..936b2f23 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle, NewsPostingEmbargo
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -124,6 +124,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -141,11 +146,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -177,7 +184,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contrib = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name, email=user.email)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -210,6 +219,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 33d4d05f..9bedc4cd 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge, Badgeholder
class ContributorAdminForm(forms.ModelForm):
@@ -22,5 +23,21 @@ class ContributorAdmin(admin.ModelAdmin):
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
+admin.site.register(Badgeholder)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..a2bc587f
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,106 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge, Badgeholder
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+from datetime import date
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+ retire_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to retire.")
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Retired badge holders", help_text="Select one or more users to remove. To un-retire a user, add their email address again in the first field.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'imagedata', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['add_holder', 'retire_holder', 'remove_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['retire_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=True)
+ self.fields['remove_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=False)
+ else:
+ del self.fields['add_holder']
+ del self.fields['retire_holder']
+ del self.fields['remove_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ badge = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ holder = Badgeholder.objects.get_or_create(user=user, badge=badge, awarded_by=get_current_user())[0]
+ if holder.date_retired: # un-retire them
+ holder.date_retired = None
+ holder.save()
+ ops.append('Un-retired badge holder {}'.format(user.username))
+ else:
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'retire_holder' in self.cleaned_data and self.cleaned_data['retire_holder']:
+ for toretire in self.cleaned_data['retire_holder']:
+ Badgeholder.objects.filter(user=toretire).update(date_retired=date.today())
+ ops.append('Retired badge holder {}'.format(toretire.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ badge.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, badge),
+ "The following changes were made to {}:\n\n{}".format(badge, "\n".join(ops))
+ )
+
+ return badge
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0001_initial.py b/pgweb/contributors/migrations/0001_initial.py
index c7d1bd17..244e2e49 100644
--- a/pgweb/contributors/migrations/0001_initial.py
+++ b/pgweb/contributors/migrations/0001_initial.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('company', models.CharField(max_length=100, null=True, blank=True)),
('companyurl', models.URLField(max_length=100, null=True, verbose_name='Company URL', blank=True)),
('location', models.CharField(max_length=100, null=True, blank=True)),
- ('contribution', models.TextField(null=True, blank=True, help_text='This description is currently used for major contributors only')),
+ ('contribution', models.TextField(null=True, blank=True, help_text='Describe what you did in the PostgreSQL community')),
],
options={
'ordering': ('lastname', 'firstname'),
diff --git a/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
new file mode 100644
index 00000000..ed6734d5
--- /dev/null
+++ b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
@@ -0,0 +1,182 @@
+# Generated by Django 5.2.16 on 2026-07-29 12:45
+
+import django.db.models.deletion
+import pgweb.util.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("contributors", "0003_make_email_nullable"),
+ ("core", "0007_usersubmissions"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="contributor",
+ name="ctype",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contributors.contributortype",
+ verbose_name="Contributor Type",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="contributor",
+ name="email",
+ field=models.EmailField(blank=True, max_length=254, null=True),
+ ),
+ migrations.AlterField(
+ model_name="contributor",
+ name="user",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.CreateModel(
+ name="Badge",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("approved", models.BooleanField(default=False)),
+ (
+ "badge",
+ models.CharField(
+ help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".',
+ max_length=32,
+ unique=True,
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="What did the people do who contributed here?",
+ null=True,
+ ),
+ ),
+ (
+ "url",
+ models.URLField(
+ blank=True,
+ help_text="URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)",
+ max_length=100,
+ null=True,
+ verbose_name="Contribution URL",
+ ),
+ ),
+ (
+ "imagedata",
+ pgweb.util.fields.ImageBinaryField(
+ blank=True,
+ help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.",
+ max_length=1000000,
+ null=True,
+ verbose_name="Image",
+ ),
+ ),
+ (
+ "contact",
+ models.CharField(
+ blank=True,
+ help_text="Contact address (email, URL, other) for people who want to be added as badge holder",
+ max_length=100,
+ null=True,
+ verbose_name="Contact address",
+ ),
+ ),
+ ("sortorder", models.IntegerField(blank=True, default=100, null=True)),
+ (
+ "org",
+ models.ForeignKey(
+ help_text='If no organisations are listed here, please check the <a href="/account/orglist/">organisation list</a> and contact the organisation manager or <a href="mailto:webmaster@postgresql.org">webmaster@postgresql.org</a> if no managers are listed. If your organisation is not listed, you can <a href="/account/edit/organisations/">create it</a>.',
+ on_delete=django.db.models.deletion.CASCADE,
+ to="core.organisation",
+ verbose_name="Organisation",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("sortorder", "badge"),
+ },
+ ),
+ migrations.CreateModel(
+ name="Badgeholder",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("date_awarded", models.DateField(auto_now_add=True)),
+ ("date_retired", models.DateField(blank=True, null=True)),
+ (
+ "awarded_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="awarded_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "badge",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contributors.badge",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": (
+ "badge__sortorder",
+ "badge",
+ "user__last_name",
+ "user__first_name",
+ ),
+ },
+ ),
+ migrations.AddField(
+ model_name="badge",
+ name="holders",
+ field=models.ManyToManyField(
+ blank=True,
+ through="contributors.Badgeholder",
+ through_fields=("badge", "user"),
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="badgeholder",
+ constraint=models.UniqueConstraint(
+ fields=("user", "badge"), name="unique_user_badge"
+ ),
+ ),
+ ]
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 8f521785..11589d36 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,9 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
+from pgweb.util.fields import ImageBinaryField
class ContributorType(models.Model):
@@ -19,22 +23,82 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
lastname = models.CharField(max_length=100, null=False, blank=False)
- email = models.EmailField(null=False, blank=True)
+ email = models.EmailField(null=True, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
- user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
+ help_text='Describe what you did in the PostgreSQL community')
+ user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ imagedata = ImageBinaryField(max_length=1000000, resolution=(150, 150), auto_scale=True, blank=True, null=True, verbose_name="Image", help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url']
+ send_notification = True
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
+
+
+class Badgeholder(models.Model):
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ badge = models.ForeignKey(Badge, on_delete=models.CASCADE)
+
+ date_awarded = models.DateField(auto_now_add=True)
+ date_retired = models.DateField(null=True, blank=True)
+ awarded_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="awarded_by")
+
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def __str__(self):
+ label = f"{self.badge} {self.user.first_name} {self.user.last_name} ({self.user})"
+ if self.date_retired:
+ label += " (retired)"
+ return label
+
+ class Meta:
+ ordering = ('badge__sortorder', 'badge', 'user__last_name', 'user__first_name')
+ constraints = [
+ models.UniqueConstraint(fields=["user", "badge"], name="unique_user_badge")
+ ]
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..f439671c 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,12 @@
+import hashlib
+
+from django.shortcuts import get_object_or_404
+from django.http import HttpResponse, HttpResponseNotModified, Http404
+
from pgweb.util.contexts import render_pgweb
+from pgweb.util.image import get_image_contenttype_from_bytes
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge, Badgeholder
def completelist(request):
@@ -8,3 +14,46 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = Contributor.objects.prefetch_related('user')
+ badges = Badge.objects.filter(approved=True)
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ # badge holders are users, but we want to show only users with a contributor object here
+ holders = Badgeholder.objects.filter(badge=badge, user__contributor__isnull=False). \
+ prefetch_related('user__contributor'). \
+ order_by('user__contributor__lastname', 'user__contributor__firstname')
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ 'holders': holders,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = Badgeholder.objects.filter(user=contributor.user, badge__approved=True)
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
+
+
+def badge_image(request, badgeid):
+ badge = get_object_or_404(Badge.objects.only('id', 'imagedata'), id=badgeid, approved=True)
+ if not badge.imagedata:
+ raise Http404
+ etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+ if request.headers.get('If-None-Match') == etag:
+ return HttpResponseNotModified()
+ return HttpResponse(
+ bytes(badge.imagedata),
+ content_type=get_image_contenttype_from_bytes(badge.imagedata[:8]),
+ )
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index 1609c780..7902e750 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index ce73c951..8bbdfdf6 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -73,9 +73,13 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+ re_path(r'^community/badge/(\d+)/image/$', pgweb.contributors.views.badge_image),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index ca493714..dc60740d 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -47,6 +47,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -86,6 +87,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 1d9a2e3c..2c054800 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -160,7 +160,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..c6c49e52 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,23 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Leave fields blank that you do not wish to make public. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +130,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/base/form_contents.html b/templates/base/form_contents.html
index 9a00c216..4f9b4263 100644
--- a/templates/base/form_contents.html
+++ b/templates/base/form_contents.html
@@ -1,5 +1,5 @@
{%load pgfilters%}
-<form class="form-horizontal" method="post" action=".">{%if not nocsrf%}{% csrf_token %}{%endif%}
+<form class="form-horizontal" method="post" action="."{% if form.is_multipart %} enctype="multipart/form-data"{% endif %}>{%if not nocsrf%}{% csrf_token %}{%endif%}
{%if form.errors %}
<div class="alert alert-danger">
Please correct the errors below, and re-submit the form.
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..0e2cee40
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,49 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
+<p><img src="{%if badge.imagedata %}/community/badge/{{badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Badge Holders</h2>
+ {%if holders|length > 50%}
+ {%for u in holders.all %}
+ <nobr>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%}</em>{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+ {%else%}
+ <ul>
+ {%for u in holders.all%}
+ <li>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%} (retired from this activity)</em>{%endif%}</li>
+ {%endfor%}
+ </ul>
+ {%endif%}
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..8377a8e7 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
-<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-
-<p>These are the fine people that make PostgreSQL what it is today!</p>
+<h1>PostgreSQL Recognized Contributors <i class="fa fa-users"></i></h1>
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people who make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people/">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..ce192cd7
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,36 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people who have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+
+<div class="badges-grid">
+{%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+{%endfor%}
+</div>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..fc2eaabc
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,57 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.badge.id}}/">
+ <img src="{%if b.badge.imagedata %}/community/badge/{{b.badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b.badge}}{%if b.date_retired%}<br><em>(retired)</em>{%endif%}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.53.0
--KPM3iI1sUdlvA3ti--
^ permalink raw reply [nested|flat] 21+ messages in thread
* [PATCH v7 3/5] Introduce contributor badges
@ 2026-04-24 16:16 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:16 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable, and NULL entries will not show up on the contributors page. If
a user later contributes more, we can simply set the field to the
desired recognized contributors status.)
Badges are represented by pictures. These are currently stored
externally and will manually be added to the pgweb repo in media/badges/
(or be external URLs). We can move them into the database later if
manual management of them turns out to be too much work.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- The badge image is mailed to contributors@postgresql.org
- The contributors team adds the image to git and updates the filename
stored in the Badge object
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
media/css/main.css | 22 +++++
pgweb/account/forms.py | 2 +-
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 +++-
pgweb/contributors/admin.py | 18 +++-
pgweb/contributors/forms.py | 92 +++++++++++++++++++
pgweb/contributors/migrations/0001_initial.py | 2 +-
pgweb/contributors/models.py | 50 +++++++++-
pgweb/contributors/views.py | 34 ++++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 5 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 22 ++++-
templates/contributors/badge.html | 49 ++++++++++
templates/contributors/list.html | 29 +++---
templates/contributors/people.html | 36 ++++++++
templates/contributors/profile.html | 57 ++++++++++++
19 files changed, 417 insertions(+), 33 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/media/css/main.css b/media/css/main.css
index 7e9ebf18..2e823553 100644
--- a/media/css/main.css
+++ b/media/css/main.css
@@ -1737,6 +1737,28 @@ table.contributor-table thead tr th {
width: 50%;
}
+.badges-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px;
+}
+.badges-cell {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+.badges-cell img {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+}
+.badges-cell figcaption {
+ text-align: center;
+ padding: 4px;
+}
+
/* News archive */
#pgContentWrap span.newstag {
white-space: nowrap;
diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py
index 1d0d8907..dba18aff 100644
--- a/pgweb/account/forms.py
+++ b/pgweb/account/forms.py
@@ -173,7 +173,7 @@ class UserForm(forms.ModelForm):
class ContributorForm(forms.ModelForm):
class Meta:
model = Contributor
- exclude = ('ctype', 'user', ) # these fields are not user-editable
+ exclude = ('ctype', 'user', ) # these fields are not user-editable
class AddEmailForm(forms.Form):
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index cbc0bbf9..936b2f23 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle, NewsPostingEmbargo
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -124,6 +124,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -141,11 +146,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -177,7 +184,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contrib = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name, email=user.email)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -210,6 +219,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 33d4d05f..71024b36 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge
class ContributorAdminForm(forms.ModelForm):
@@ -22,5 +23,20 @@ class ContributorAdmin(admin.ModelAdmin):
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..54dbdd23
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,92 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to remove")
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'image', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['remove_holder', 'add_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['remove_holder'].queryset = self.instance.holders
+ else:
+ del self.fields['remove_holder']
+ del self.fields['add_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ model = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ model.holders.add(user)
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ model.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, model),
+ "The following changes were made to {}:\n\n{}".format(model, "\n".join(ops))
+ )
+
+ return model
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0001_initial.py b/pgweb/contributors/migrations/0001_initial.py
index c7d1bd17..244e2e49 100644
--- a/pgweb/contributors/migrations/0001_initial.py
+++ b/pgweb/contributors/migrations/0001_initial.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('company', models.CharField(max_length=100, null=True, blank=True)),
('companyurl', models.URLField(max_length=100, null=True, verbose_name='Company URL', blank=True)),
('location', models.CharField(max_length=100, null=True, blank=True)),
- ('contribution', models.TextField(null=True, blank=True, help_text='This description is currently used for major contributors only')),
+ ('contribution', models.TextField(null=True, blank=True, help_text='Describe what you did in the PostgreSQL community')),
],
options={
'ordering': ('lastname', 'firstname'),
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 8f521785..4a0532ba 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,8 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
class ContributorType(models.Model):
@@ -19,22 +22,59 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
lastname = models.CharField(max_length=100, null=False, blank=False)
- email = models.EmailField(null=False, blank=True)
+ email = models.EmailField(null=True, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
- user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
+ help_text='Describe what you did in the PostgreSQL community')
+ user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ image = models.CharField(max_length=100, verbose_name='Path to contribution image', null=True, blank=True, help_text="Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url', 'image']
+ send_notification = True
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..ee0124ac 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,8 @@
+from django.shortcuts import get_object_or_404
+
from pgweb.util.contexts import render_pgweb
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge
def completelist(request):
@@ -8,3 +10,33 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = Contributor.objects.prefetch_related('user')
+ badges = Badge.objects.filter(approved=True)
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ # badge holders are users, but we want to show only users with a contributor object here
+ holders = badge.holders.filter(contributor__isnull=False). \
+ prefetch_related('contributor'). \
+ order_by('contributor__lastname', 'contributor__firstname')
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ 'holders': holders,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = Badge.objects.filter(approved=True, holders=contributor.user)
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index 1609c780..7902e750 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index ce73c951..50eb4acc 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -73,9 +73,12 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index ca493714..dc60740d 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -47,6 +47,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -86,6 +87,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 1d9a2e3c..2c054800 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -160,7 +160,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..4d6fc04a 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,23 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Leave fields blank that you do not wish to make public. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +130,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..16c995ca
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,49 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
+<p><img src="{%if badge.image %}{{badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Badge Holders</h2>
+ {%if holders|length > 50%}
+ {%for u in holders.all %}
+ <nobr><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a> {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+ {%else%}
+ <ul>
+ {%for u in holders.all%}
+ <li><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a></li>
+ {%endfor%}
+ </ul>
+ {%endif%}
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..8377a8e7 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
-<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-
-<p>These are the fine people that make PostgreSQL what it is today!</p>
+<h1>PostgreSQL Recognized Contributors <i class="fa fa-users"></i></h1>
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people who make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people/">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..20950de3
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,36 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people who have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+
+<div class="badges-grid">
+{%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+{%endfor%}
+</div>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..eca38dc2
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,57 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.53.0
--KPM3iI1sUdlvA3ti
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename=v7-0004-Add-dates-on-badgeholders-so-people-can-be-retire.patch
^ permalink raw reply [nested|flat] 21+ messages in thread
* [PATCH v8 3/3] Introduce contributor badges
@ 2026-04-24 16:16 Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:16 UTC (permalink / raw)
Previously only recognized contributors would be listed on the
contributor profiles page. In order to be able to recognize more people,
we introduce contributor badges. A badge is an award for a contribution
to PostgreSQL like "PostgreSQL 18 Contributor" (as per the contributor
list in the release notes), "PGConf.EU 2025 Speaker", "London PG Meetup
Organizer" or anything else that contributed to PostgreSQL in a positive
way. Ecosystem projects are invited to create badges, e.g. "Patroni
Contributor" or "Talking Postgres Guest".
Technically, we introduce a new "Badge" Django object in the
contributors namespace, to be administrated by the contributors team.
The existing "submit a new xxx" workflow is extended to include badges
which are tied to organisations like the existing other objects. New
badges are subject to moderation, but once approved, the submitting
organisation can edit the badge, most importantly to add more badge
holders.
Once an individual has been awarded a badge, they can create a profile
page from their account page, and optionally supply a bio. This extends
the existing "Contributor" object in Django, but the page is put under
the /community/people/ tree to separate it from the existing
/community/contributors/ page which will be kept as the curated list of
recognized Major and Significant Contributors. (contributor.ctype is now
NULLable, and NULL entries will not show up on the contributors page. If
a user later contributes more, we can simply set the field to the
desired recognized contributors status.)
Badges are represented by pictures, preferably square and 150x150,
stored in a ImageBinaryField.
The badges workflow can be summarized as follows:
- An individual can submit the form to make an Organization
- A pgweb moderator can approve the organization
- An Organization manager can submit the form to make a new Badge
including contact info for requesting badge holder status, and a badge
image
- A pgweb moderator can approve the new badge
- An Organization manager can add Badge holders (before or after
approval of the Badge but the holders won't show anywhere until
approved)
- A User can request a badge to become a Badge holder by emailing the
badge contact info on the Badge page
- A User who is a Badge holder can check a box to get a profile page
(makes a Contributor table entry, but we do not formally call them
contributors in the pages)
- The user can fill in their contributors bio to have it show up in
/community/people/<username>/.
New Pages introduced:
community/people/<username>/
-- profile page
community/people/
-- all badge holders and all badges
community/badge/<number>/
-- badge info (image, description text, URL)
-- badge holders
-- badge request contact email address (hopefully if they added it)
community/badge/<number>/image/
-- the badge image
Author: Melanie Plageman <melanieplageman@gmail.com>
Author: Christoph Berg <myon@debian.org>
---
media/css/main.css | 22 +++
pgweb/account/forms.py | 2 +-
pgweb/account/urls.py | 2 +-
pgweb/account/views.py | 16 +-
pgweb/contributors/admin.py | 19 +-
pgweb/contributors/forms.py | 106 ++++++++++
pgweb/contributors/migrations/0001_initial.py | 2 +-
..._ctype_alter_contributor_email_and_more.py | 182 ++++++++++++++++++
pgweb/contributors/models.py | 74 ++++++-
pgweb/contributors/views.py | 51 ++++-
pgweb/core/forms.py | 2 +-
pgweb/urls.py | 6 +-
pgweb/util/contexts.py | 2 +
pgweb/util/moderation.py | 3 +-
templates/account/index.html | 7 +-
templates/account/userprofileform.html | 22 ++-
templates/base/form_contents.html | 2 +-
templates/contributors/badge.html | 49 +++++
templates/contributors/list.html | 29 +--
templates/contributors/people.html | 36 ++++
templates/contributors/profile.html | 57 ++++++
21 files changed, 657 insertions(+), 34 deletions(-)
create mode 100644 pgweb/contributors/forms.py
create mode 100644 pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
create mode 100644 templates/contributors/badge.html
create mode 100644 templates/contributors/people.html
create mode 100644 templates/contributors/profile.html
diff --git a/media/css/main.css b/media/css/main.css
index 7e9ebf18..2e823553 100644
--- a/media/css/main.css
+++ b/media/css/main.css
@@ -1737,6 +1737,28 @@ table.contributor-table thead tr th {
width: 50%;
}
+.badges-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px;
+}
+.badges-cell {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+.badges-cell img {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+}
+.badges-cell figcaption {
+ text-align: center;
+ padding: 4px;
+}
+
/* News archive */
#pgContentWrap span.newstag {
white-space: nowrap;
diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py
index 1d0d8907..dba18aff 100644
--- a/pgweb/account/forms.py
+++ b/pgweb/account/forms.py
@@ -173,7 +173,7 @@ class UserForm(forms.ModelForm):
class ContributorForm(forms.ModelForm):
class Meta:
model = Contributor
- exclude = ('ctype', 'user', ) # these fields are not user-editable
+ exclude = ('ctype', 'user', ) # these fields are not user-editable
class AddEmailForm(forms.Form):
diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py
index 12ad5955..a5621d22 100644
--- a/pgweb/account/urls.py
+++ b/pgweb/account/urls.py
@@ -26,7 +26,7 @@ urlpatterns = [
# Submitted items
re_path(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
- re_path(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
+ re_path(r'^(?P<objtype>news|events|products|organisations|services|badges)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
re_path(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
# Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
diff --git a/pgweb/account/views.py b/pgweb/account/views.py
index cbc0bbf9..936b2f23 100644
--- a/pgweb/account/views.py
+++ b/pgweb/account/views.py
@@ -35,7 +35,7 @@ from pgweb.news.models import NewsArticle, NewsPostingEmbargo
from pgweb.events.models import Event
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
from pgweb.core.models import OrganisationEmail
-from pgweb.contributors.models import Contributor
+from pgweb.contributors.models import Contributor, Badge
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
@@ -124,6 +124,11 @@ objtypes = {
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
'editapproved': True,
},
+ 'badges': {
+ 'title': 'contributor badge',
+ 'objects': lambda u: Badge.objects.filter(org__managers=u),
+ 'editapproved': True,
+ },
}
@@ -141,11 +146,13 @@ def profile(request):
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
# We may have a contributor record - and we only show that part of the
- # form if we have it for this user.
+ # form if we have it for this user. If the user has any badges awarded,
+ # offer them to create the contributor record.
try:
contrib = Contributor.objects.get(user=request.user.pk)
except Contributor.DoesNotExist:
contrib = None
+ badges = Badge.objects.filter(approved=True, holders=request.user.pk)
contribform = None
@@ -177,7 +184,9 @@ def profile(request):
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
- if contrib:
+ if badges and 'create_contributor_profile' in request.POST and request.POST['create_contributor_profile']:
+ contrib = Contributor.objects.create(user=user, firstname=user.first_name, lastname=user.last_name, email=user.email)
+ elif contrib:
contribform.save()
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
@@ -210,6 +219,7 @@ def profile(request):
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
+ 'badges': badges,
'contribform': contribform,
})
diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 33d4d05f..9bedc4cd 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db.models import Count
-from .models import Contributor, ContributorType
+from .models import Contributor, ContributorType, Badge, Badgeholder
class ContributorAdminForm(forms.ModelForm):
@@ -22,5 +23,21 @@ class ContributorAdmin(admin.ModelAdmin):
search_fields = ('firstname', 'lastname', 'user__username',)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'holders_count', 'approved', 'org',)
+ list_filter = ('approved', 'org',)
+ autocomplete_fields = ['holders', ]
+
+ def holders_count(self, obj):
+ return obj.holders_count
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ queryset = queryset.annotate(holders_count=Count("holders"))
+ return queryset
+
+
admin.site.register(ContributorType)
admin.site.register(Contributor, ContributorAdmin)
+admin.site.register(Badge, BadgeAdmin)
+admin.site.register(Badgeholder)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
new file mode 100644
index 00000000..a2bc587f
--- /dev/null
+++ b/pgweb/contributors/forms.py
@@ -0,0 +1,106 @@
+from django import forms
+from django.forms import ValidationError
+from django.conf import settings
+
+from pgweb.core.models import Organisation
+from .models import Contributor, Badge, Badgeholder
+from django.contrib.auth.models import User
+
+from pgweb.util.middleware import get_current_user
+from pgweb.mailqueue.util import send_simple_mail
+
+from datetime import date
+
+
+class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+ def label_from_instance(self, obj):
+ contributor = Contributor.objects.filter(user=obj.pk)
+ if contributor:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}>"
+ else:
+ return f"{obj.first_name} {obj.last_name} ({obj.username}) <{obj.email}> (not a contributor yet)"
+
+
+class BadgeForm(forms.ModelForm):
+ form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
+
+ add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+ retire_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to retire.")
+ remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Retired badge holders", help_text="Select one or more users to remove. To un-retire a user, add their email address again in the first field.")
+
+ fieldsets = [
+ {
+ 'id': 'general',
+ 'legend': 'Contributor Badge',
+ 'fields': ['org', 'badge', 'description', 'url', 'imagedata', 'contact', ],
+ },
+ {
+ 'id': 'holders',
+ 'legend': 'Badge Holders',
+ 'fields': ['add_holder', 'retire_holder', 'remove_holder'],
+ },
+ ]
+
+ class Meta:
+ model = Badge
+ exclude = ('approved', 'sortorder', 'holders',)
+
+ def __init__(self, *args, **kwargs):
+ super(BadgeForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['retire_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=True)
+ self.fields['remove_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=False)
+ else:
+ del self.fields['add_holder']
+ del self.fields['retire_holder']
+ del self.fields['remove_holder']
+ # remove the holders fieldset
+ self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
+
+ def clean_add_holder(self):
+ if self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ # something was added - let's make sure the user exists
+ try:
+ User.objects.get(email=u.lower())
+ except User.DoesNotExist:
+ raise ValidationError("User with email %s not found" % u)
+
+ return self.cleaned_data['add_holder']
+
+ def save(self, commit=True):
+ badge = super(BadgeForm, self).save(commit=False)
+
+ ops = []
+
+ if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
+ for u in self.cleaned_data['add_holder'].split():
+ user = User.objects.get(email=u.lower())
+ holder = Badgeholder.objects.get_or_create(user=user, badge=badge, awarded_by=get_current_user())[0]
+ if holder.date_retired: # un-retire them
+ holder.date_retired = None
+ holder.save()
+ ops.append('Un-retired badge holder {}'.format(user.username))
+ else:
+ ops.append('Added badge holder {}'.format(user.username))
+ if 'retire_holder' in self.cleaned_data and self.cleaned_data['retire_holder']:
+ for toretire in self.cleaned_data['retire_holder']:
+ Badgeholder.objects.filter(user=toretire).update(date_retired=date.today())
+ ops.append('Retired badge holder {}'.format(toretire.username))
+ if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
+ for toremove in self.cleaned_data['remove_holder']:
+ badge.holders.remove(toremove)
+ ops.append('Removed badge holder {}'.format(toremove.username))
+
+ if ops:
+ send_simple_mail(
+ settings.NOTIFICATION_FROM,
+ settings.NOTIFICATION_EMAIL,
+ "{0} modified {1}".format(get_current_user().username, badge),
+ "The following changes were made to {}:\n\n{}".format(badge, "\n".join(ops))
+ )
+
+ return badge
+
+ def filter_by_user(self, user):
+ self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0001_initial.py b/pgweb/contributors/migrations/0001_initial.py
index c7d1bd17..244e2e49 100644
--- a/pgweb/contributors/migrations/0001_initial.py
+++ b/pgweb/contributors/migrations/0001_initial.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('company', models.CharField(max_length=100, null=True, blank=True)),
('companyurl', models.URLField(max_length=100, null=True, verbose_name='Company URL', blank=True)),
('location', models.CharField(max_length=100, null=True, blank=True)),
- ('contribution', models.TextField(null=True, blank=True, help_text='This description is currently used for major contributors only')),
+ ('contribution', models.TextField(null=True, blank=True, help_text='Describe what you did in the PostgreSQL community')),
],
options={
'ordering': ('lastname', 'firstname'),
diff --git a/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
new file mode 100644
index 00000000..ed6734d5
--- /dev/null
+++ b/pgweb/contributors/migrations/0004_alter_contributor_ctype_alter_contributor_email_and_more.py
@@ -0,0 +1,182 @@
+# Generated by Django 5.2.16 on 2026-07-29 12:45
+
+import django.db.models.deletion
+import pgweb.util.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("contributors", "0003_make_email_nullable"),
+ ("core", "0007_usersubmissions"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="contributor",
+ name="ctype",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contributors.contributortype",
+ verbose_name="Contributor Type",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="contributor",
+ name="email",
+ field=models.EmailField(blank=True, max_length=254, null=True),
+ ),
+ migrations.AlterField(
+ model_name="contributor",
+ name="user",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.CreateModel(
+ name="Badge",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("approved", models.BooleanField(default=False)),
+ (
+ "badge",
+ models.CharField(
+ help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".',
+ max_length=32,
+ unique=True,
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="What did the people do who contributed here?",
+ null=True,
+ ),
+ ),
+ (
+ "url",
+ models.URLField(
+ blank=True,
+ help_text="URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)",
+ max_length=100,
+ null=True,
+ verbose_name="Contribution URL",
+ ),
+ ),
+ (
+ "imagedata",
+ pgweb.util.fields.ImageBinaryField(
+ blank=True,
+ help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.",
+ max_length=1000000,
+ null=True,
+ verbose_name="Image",
+ ),
+ ),
+ (
+ "contact",
+ models.CharField(
+ blank=True,
+ help_text="Contact address (email, URL, other) for people who want to be added as badge holder",
+ max_length=100,
+ null=True,
+ verbose_name="Contact address",
+ ),
+ ),
+ ("sortorder", models.IntegerField(blank=True, default=100, null=True)),
+ (
+ "org",
+ models.ForeignKey(
+ help_text='If no organisations are listed here, please check the <a href="/account/orglist/">organisation list</a> and contact the organisation manager or <a href="mailto:webmaster@postgresql.org">webmaster@postgresql.org</a> if no managers are listed. If your organisation is not listed, you can <a href="/account/edit/organisations/">create it</a>.',
+ on_delete=django.db.models.deletion.CASCADE,
+ to="core.organisation",
+ verbose_name="Organisation",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("sortorder", "badge"),
+ },
+ ),
+ migrations.CreateModel(
+ name="Badgeholder",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("date_awarded", models.DateField(auto_now_add=True)),
+ ("date_retired", models.DateField(blank=True, null=True)),
+ (
+ "awarded_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="awarded_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "badge",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contributors.badge",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": (
+ "badge__sortorder",
+ "badge",
+ "user__last_name",
+ "user__first_name",
+ ),
+ },
+ ),
+ migrations.AddField(
+ model_name="badge",
+ name="holders",
+ field=models.ManyToManyField(
+ blank=True,
+ through="contributors.Badgeholder",
+ through_fields=("badge", "user"),
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="badgeholder",
+ constraint=models.UniqueConstraint(
+ fields=("user", "badge"), name="unique_user_badge"
+ ),
+ ),
+ ]
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 8f521785..11589d36 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -1,5 +1,9 @@
from django.db import models
from django.contrib.auth.models import User
+from pgweb.core.models import Organisation
+from pgweb.core.text import ORGANISATION_HINT_TEXT
+from pgweb.util.moderation import TwostateModerateModel
+from pgweb.util.fields import ImageBinaryField
class ContributorType(models.Model):
@@ -19,22 +23,82 @@ class ContributorType(models.Model):
class Contributor(models.Model):
- ctype = models.ForeignKey(ContributorType, on_delete=models.CASCADE, verbose_name='Contributor Type')
+ ctype = models.ForeignKey(ContributorType,
+ on_delete=models.CASCADE,
+ verbose_name='Contributor Type', null=True, blank=True)
firstname = models.CharField(max_length=100, null=False, blank=False)
lastname = models.CharField(max_length=100, null=False, blank=False)
- email = models.EmailField(null=False, blank=True)
+ email = models.EmailField(null=True, blank=True)
company = models.CharField(max_length=100, null=True, blank=True)
companyurl = models.URLField(max_length=100, null=True, blank=True, verbose_name='Company URL')
location = models.CharField(max_length=100, null=True, blank=True)
contribution = models.TextField(null=True, blank=True,
- help_text='This description is currently used for major contributors only')
- user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
+ help_text='Describe what you did in the PostgreSQL community')
+ user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE)
send_notification = True
- purge_urls = ('/community/contributors/', )
+ purge_urls = ('/community/contributors/', '/community/people/', '/community/badge/')
def __str__(self):
return "%s %s" % (self.firstname, self.lastname)
class Meta:
ordering = ('lastname', 'firstname',)
+
+
+class Badge(TwostateModerateModel):
+ org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text=ORGANISATION_HINT_TEXT, on_delete=models.CASCADE)
+ badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
+ description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
+ url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
+ imagedata = ImageBinaryField(max_length=1000000, resolution=(150, 150), auto_scale=True, blank=True, null=True, verbose_name="Image", help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.")
+ contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
+ holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
+
+ sortorder = models.IntegerField(null=True, blank=True, default=100)
+
+ account_edit_suburl = 'badges'
+ moderation_fields = ['badge', 'description', 'url']
+ send_notification = True
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def verify_submitter(self, user):
+ return (len(self.org.managers.filter(pk=user.pk)) == 1)
+
+ def __str__(self):
+ return self.badge
+
+ @property
+ def title(self):
+ return self.badge
+
+ class Meta:
+ ordering = ('sortorder', 'badge')
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.contributors.forms import BadgeForm
+ return BadgeForm
+
+
+class Badgeholder(models.Model):
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ badge = models.ForeignKey(Badge, on_delete=models.CASCADE)
+
+ date_awarded = models.DateField(auto_now_add=True)
+ date_retired = models.DateField(null=True, blank=True)
+ awarded_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="awarded_by")
+
+ purge_urls = ('/community/people/', '/community/badge/')
+
+ def __str__(self):
+ label = f"{self.badge} {self.user.first_name} {self.user.last_name} ({self.user})"
+ if self.date_retired:
+ label += " (retired)"
+ return label
+
+ class Meta:
+ ordering = ('badge__sortorder', 'badge', 'user__last_name', 'user__first_name')
+ constraints = [
+ models.UniqueConstraint(fields=["user", "badge"], name="unique_user_badge")
+ ]
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 45b8551e..f439671c 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,12 @@
+import hashlib
+
+from django.shortcuts import get_object_or_404
+from django.http import HttpResponse, HttpResponseNotModified, Http404
+
from pgweb.util.contexts import render_pgweb
+from pgweb.util.image import get_image_contenttype_from_bytes
-from .models import ContributorType
+from .models import ContributorType, Contributor, Badge, Badgeholder
def completelist(request):
@@ -8,3 +14,46 @@ def completelist(request):
return render_pgweb(request, 'community', 'contributors/list.html', {
'contributortypes': contributortypes,
})
+
+
+def peoplelist(request):
+ people = Contributor.objects.prefetch_related('user')
+ badges = Badge.objects.filter(approved=True)
+ return render_pgweb(request, 'community', 'contributors/people.html', {
+ 'people': people,
+ 'badges': badges,
+ })
+
+
+def badge_view(request, badgeid):
+ badge = get_object_or_404(Badge, id=badgeid, approved=True)
+ # badge holders are users, but we want to show only users with a contributor object here
+ holders = Badgeholder.objects.filter(badge=badge, user__contributor__isnull=False). \
+ prefetch_related('user__contributor'). \
+ order_by('user__contributor__lastname', 'user__contributor__firstname')
+ return render_pgweb(request, 'community', 'contributors/badge.html', {
+ 'badge': badge,
+ 'holders': holders,
+ })
+
+
+def profile(request, username):
+ contributor = get_object_or_404(Contributor, user__username=username)
+ badges = Badgeholder.objects.filter(user=contributor.user, badge__approved=True)
+ return render_pgweb(request, 'community', 'contributors/profile.html', {
+ 'contributor': contributor,
+ 'badges': badges,
+ })
+
+
+def badge_image(request, badgeid):
+ badge = get_object_or_404(Badge.objects.only('id', 'imagedata'), id=badgeid, approved=True)
+ if not badge.imagedata:
+ raise Http404
+ etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+ if request.headers.get('If-None-Match') == etag:
+ return HttpResponseNotModified()
+ return HttpResponse(
+ bytes(badge.imagedata),
+ content_type=get_image_contenttype_from_bytes(badge.imagedata[:8]),
+ )
diff --git a/pgweb/core/forms.py b/pgweb/core/forms.py
index 1609c780..7902e750 100644
--- a/pgweb/core/forms.py
+++ b/pgweb/core/forms.py
@@ -13,7 +13,7 @@ from pgweb.util.misc import send_template_mail, generate_random_token
class OrganisationForm(forms.ModelForm):
new_form_intro = """<em>Note!</em> An organisation record is only needed to post news, events,
-products or professional services. In particular, it is <em>not</em> necessary to register an
+products, professional services or contributor badges. In particular, it is <em>not</em> necessary to register an
organisation in order to ask questions or otherwise participate on the PostgreSQL mailing lists, file a bug
report, or otherwise interact with the community."""
diff --git a/pgweb/urls.py b/pgweb/urls.py
index ce73c951..8bbdfdf6 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -73,9 +73,13 @@ urlpatterns = [
re_path(r'^community/$', pgweb.core.views.community),
re_path(r'^community/contributors/$', pgweb.contributors.views.completelist),
+ re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
+ re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
+ re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+ re_path(r'^community/badge/(\d+)/image/$', pgweb.contributors.views.badge_image),
+
re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/';, permanent=True)),
-
re_path(r'^community/lists/listinfo/$', pgweb.lists.views.listinfo),
re_path(r'^community/recognition/$', RedirectView.as_view(url='/about/policies/', permanent=True)),
re_path(r'^community/survey/vote/(\d+)/$', pgweb.survey.views.vote),
diff --git a/pgweb/util/contexts.py b/pgweb/util/contexts.py
index ca493714..dc60740d 100644
--- a/pgweb/util/contexts.py
+++ b/pgweb/util/contexts.py
@@ -47,6 +47,7 @@ sitenav = {
'community': [
{'title': 'Community', 'link': '/community/'},
{'title': 'Contributors', 'link': '/community/contributors/'},
+ {'title': 'People', 'link': '/community/people/'},
{'title': 'Mailing Lists', 'link': '/list/'},
{'title': 'IRC', 'link': '/community/irc/'},
# {'title': 'Slack', 'link': 'https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA';},
@@ -86,6 +87,7 @@ sitenav = {
{'title': 'Events', 'link': '/account/edit/events/'},
{'title': 'Products', 'link': '/account/edit/products/'},
{'title': 'Professional Services', 'link': '/account/edit/services/'},
+ {'title': 'Contributor Badges', 'link': '/account/edit/badges/'},
{'title': 'Organisations', 'link': '/account/edit/organisations/'},
]},
{'title': 'Change password', 'link': '/account/changepwd/'},
diff --git a/pgweb/util/moderation.py b/pgweb/util/moderation.py
index 1d9a2e3c..2c054800 100644
--- a/pgweb/util/moderation.py
+++ b/pgweb/util/moderation.py
@@ -160,7 +160,8 @@ def _modclasses():
from pgweb.core.models import Organisation
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
- return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+ from pgweb.contributors.models import Badge
+ return [NewsArticle, Event, Organisation, Product, ProfessionalService, Badge]
def get_all_pending_moderations():
diff --git a/templates/account/index.html b/templates/account/index.html
index 94761e00..4fb74a2e 100644
--- a/templates/account/index.html
+++ b/templates/account/index.html
@@ -5,8 +5,9 @@
<p>
From this section, you can manage all information on this site connected
to your PostgreSQL community account. Other than your basic profile
-information, this includes news and events, professional services and
-comments. Note that most of the data you submit to this site needs to be
+information, this includes news and events, professional services,
+comments and contributor badges.
+Note that most of the data you submit to this site needs to be
approved by a moderator before it's published.
</p>
<p>
@@ -16,7 +17,7 @@ type of record in the menu on the left.
<h2>Permissions model</h2>
<p>
-News, Events, Products and Professional Services are attached to an
+News, Events, Products, Professional Services and Contributor Badges are attached to an
Organisation. One or more persons can be given permissions to manage
the data for an organisation. If you do not have permissions for an
organisation and think you should have, you need to contact the current
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 96ed2779..c6c49e52 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -94,9 +94,23 @@
</div>
{%endfor%}
+ {% if badges %}
+ <h2>Contributor badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {% endif %}
+
{% if contribform %}
<h2>Edit contributor information</h2>
- <p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank" rel="noopener">contributors</a> page. Please be careful as your changes will take effect immediately!
+ <p>You can edit the information that's shown on your <a href="/community/people/{{user.username}}/" target="_blank" rel="noopener">profile</a> page. Leave fields blank that you do not wish to make public. Please be careful as your changes will take effect immediately!
</p>
{% for field in contribform %}
<div class="form-group row">
@@ -116,6 +130,12 @@
</div>
</div>
{% endfor %}
+ {% elif badges %}
+ <h2>Create contributor profile</h2>
+ <p>You have been awarded contributor badges. To have your name included in the public people and badge holder lists, you can create a publicly visible profile page.</p>
+ <div>
+ <input type="checkbox" name="create_contributor_profile"> Create publicly visible profile page
+ </div>
{% endif %}
<div class="submit-row">
diff --git a/templates/base/form_contents.html b/templates/base/form_contents.html
index 9a00c216..4f9b4263 100644
--- a/templates/base/form_contents.html
+++ b/templates/base/form_contents.html
@@ -1,5 +1,5 @@
{%load pgfilters%}
-<form class="form-horizontal" method="post" action=".">{%if not nocsrf%}{% csrf_token %}{%endif%}
+<form class="form-horizontal" method="post" action="."{% if form.is_multipart %} enctype="multipart/form-data"{% endif %}>{%if not nocsrf%}{% csrf_token %}{%endif%}
{%if form.errors %}
<div class="alert alert-danger">
Please correct the errors below, and re-submit the form.
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
new file mode 100644
index 00000000..0e2cee40
--- /dev/null
+++ b/templates/contributors/badge.html
@@ -0,0 +1,49 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{badge}} - Contribution{%endblock%}
+{%block contents%}
+<h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
+<p><img src="{%if badge.imagedata %}/community/badge/{{badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+
+{% if badge.description or badge.url %}
+<h2>Contribution</h2>
+{%endif%}
+{% if badge.description %}
+<p>{{badge.description}}</p>
+{%endif%}
+{% if badge.url %}
+<p><a href="{{badge.url}}">{{badge.url}}</a></p>
+{%endif%}
+
+{%if badge.holders%}
+<h2>Badge Holders</h2>
+ {%if holders|length > 50%}
+ {%for u in holders.all %}
+ <nobr>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%}</em>{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+ {%else%}
+ <ul>
+ {%for u in holders.all%}
+ <li>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%} (retired from this activity)</em>{%endif%}</li>
+ {%endfor%}
+ </ul>
+ {%endif%}
+{%endif%}
+
+<h2>Getting added</h2>
+{% if badge.org %}
+<p>This badge is issued by <em>{{badge.org}}</em>.</p>
+{%endif%}
+
+<p>If you think you should be listed
+ here, check your <a href="/account/profile/">account profile</a> page if the
+ badge is present there and activate your contribution profile page. If not,
+ contact the badge issuing organisation and ask to be added.</p>
+
+{% if badge.contact %}
+<p>Contact information: <em>{{badge.contact}}</em>.</p>
+{%endif%}
+
+<p>People are listed in alphabetical order.</p>
+
+{%endblock%}
diff --git a/templates/contributors/list.html b/templates/contributors/list.html
index 068fc949..8377a8e7 100644
--- a/templates/contributors/list.html
+++ b/templates/contributors/list.html
@@ -1,18 +1,18 @@
{%extends "base/page.html"%}
{%load pgfilters%}
-{%block title%}Contributor Profiles{%endblock%}
+{%block title%}Recognized Contributors{%endblock%}
{%block contents%}
-<h1>PostgreSQL Contributor Profiles <i class="fa fa-users"></i></h1>
-
-<p>These are the fine people that make PostgreSQL what it is today!</p>
+<h1>PostgreSQL Recognized Contributors <i class="fa fa-users"></i></h1>
<p>
- For a list of all code contributions to a specific release, see the
- <a href="/docs/release/">Release Notes</a> for released versions of PostgreSQL.
+ These are the fine people who make PostgreSQL what it is today!
+ Contributors listed here have provided a sustained stream of notable contributions in recent years.
</p>
+
<p>
- Existing contributors can update their information in their
- <a href="/account/profile/">user profile</a>.
+ A separate page lists all
+ <a href="/community/people/">people who have made contributions to PostgreSQL</a>
+ over the years.
</p>
{%for t in contributortypes%}
@@ -32,7 +32,7 @@
{%for c in t.contributor_set.all %}
{%if t.detailed%}
<tr>
- <td>{{c.firstname}} {{c.lastname}} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %} {%if t.showemail and c.email%}({{c.email|hidemail}}){%endif%}
{%if c.company %}
<br/>
{% if c.companyurl %}
@@ -41,21 +41,23 @@
{{c.company}}
{% endif %}
{% endif %}
- <br/>
- {{c.location}}
+ {%if c.location %}
+ <br/>
+ {{c.location}}
+ {% endif %}
</td>
<td>{{c.contribution}}</td>
</tr>
{%else%}
{%if forloop.counter0|divisibleby:"2" %}
<tr>
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
{%if forloop.last%}
<td></td>
</tr>
{%endif%}
{%else%}
- <td>{{c.firstname}} {{c.lastname}}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
+ <td>{% if c.user %}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{% else %}{{c.firstname}} {{c.lastname}}{% endif %}{%if t.showemail and c.email%} ({{c.email|hidemail}}){%endif%}</td>
</tr>
{%endif%}
{%endif%}
@@ -65,6 +67,7 @@
{%endfor%}
<p>All contributors are listed in alphabetical order.
+ Existing contributors can update their information in their <a href="/account/profile/">user profile</a>.
To suggest additions or corrections to this list, please email the
<a href="/about/governance/contributors/">Contributors Committee</a> at
<a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
new file mode 100644
index 00000000..ce192cd7
--- /dev/null
+++ b/templates/contributors/people.html
@@ -0,0 +1,36 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}People in the PostgreSQL Community{%endblock%}
+{%block contents%}
+<h1>People in the PostgreSQL Community <i class="fa fa-users"></i></h1>
+<p>These are the fine people who have made contributions to PostgreSQL over the years.</p>
+
+<p>Recognized Significant and Major Contributors are listed on the
+ <a href="/community/contributors/">Contributors page</a>.
+</p>
+
+<p style="text-align: justify;">
+ {%for c in people %}
+ <nobr>{%if c.user%}<a href="/community/people/{{c.user.username}}/">{{c.firstname}} {{c.lastname}}</a>{%else%}{{c.firstname}} {{c.lastname}}{%endif%} {%if not forloop.last%}·{%endif%}</nobr>
+ {%endfor%}
+</p>
+
+<p>People are listed in alphabetical order.
+ If you are listed and want to claim your profile page, write a mail including your community account name to
+ <a href="mailto:contributors@postgresql.org">contributors@postgresql.org</a>.
+</p>
+
+<h1>Contribution Badges</h1>
+
+<div class="badges-grid">
+{%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.id}}/">
+ <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b}}</figcaption>
+ </a>
+ </figure>
+{%endfor%}
+</div>
+
+{%endblock%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
new file mode 100644
index 00000000..fc2eaabc
--- /dev/null
+++ b/templates/contributors/profile.html
@@ -0,0 +1,57 @@
+{%extends "base/page.html"%}
+{%load pgfilters%}
+{%block title%}{{contributor.firstname}} {{contributor.lastname}} - Contributor Profile{%endblock%}
+{%block contents%}
+<h1>{{contributor.firstname}} {{contributor.lastname}} <i class="fa fa-user"></i></h1>
+
+<div class="row">
+ <div class="col-md-8">
+ {%if badges%}
+ <h2>Contribution Badges</h2>
+ <div class="badges-grid">
+ {%for b in badges %}
+ <figure class="badges-cell">
+ <a href="/community/badge/{{b.badge.id}}/">
+ <img src="{%if b.badge.imagedata %}/community/badge/{{b.badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
+ <figcaption>{{b.badge}}{%if b.date_retired%}<br><em>(retired)</em>{%endif%}</figcaption>
+ </a>
+ </figure>
+ {%endfor%}
+ </div>
+ {%endif%}
+
+ {%if contributor.contribution%}
+ <h2>Contribution</h2>
+ <p>{{contributor.contribution}}</p>
+ {%endif%}
+
+ <h2>Information</h2>
+ <dl class="row">
+ <dt class="col-sm-3">Contributor Type</dt>
+ <dd class="col-sm-9">{%if contributor.ctype%}{{contributor.ctype.typename}}{%else%}Basic contributor, not recognized{%endif%}</dd>
+
+ {%if contributor.email%}
+ <dt class="col-sm-3">Email</dt>
+ <dd class="col-sm-9">{{contributor.email|hidemail}}</dd>
+ {%endif%}
+
+ {%if contributor.location%}
+ <dt class="col-sm-3">Location</dt>
+ <dd class="col-sm-9">{{contributor.location}}</dd>
+ {%endif%}
+
+ {%if contributor.company%}
+ <dt class="col-sm-3">Company</dt>
+ <dd class="col-sm-9">
+ {%if contributor.companyurl%}
+ <a href="{{contributor.companyurl}}" target="_blank" rel="noopener">{{contributor.company}}</a>
+ {%else%}
+ {{contributor.company}}
+ {%endif%}
+ </dd>
+ {%endif%}
+ </dl>
+ </div>
+</div>
+
+{%endblock%}
--
2.53.0
--KPM3iI1sUdlvA3ti--
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-04-24 16:45 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
0 siblings, 2 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:45 UTC (permalink / raw)
To: pgsql-www@lists.postgresql.org; +Cc: Andrey Borodin <x4mmm@yandex-team.ru>; Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; PostgreSQL Contributors <contributors@lists.postgresql.org>
New version attached. Not much change, the biggest one is perhaps that
lists of badges are no longer rendered as html table, but in a CSS
grid. Everything seems to work as it should now.
My plan is to commit this, minus the menu change in
pgweb/util/contexts.py. That way, we can create the first badges and
check if everything really works without things already being visible
to users (unless they know the URLs).
Christoph
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-04-24 16:47 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
1 sibling, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-04-24 16:47 UTC (permalink / raw)
To: pgsql-www@lists.postgresql.org; +Cc: Andrey Borodin <x4mmm@yandex-team.ru>; Magnus Hagander <magnus@hagander.net>; Melanie Plageman <melanieplageman@gmail.com>; PostgreSQL Contributors <contributors@lists.postgresql.org>
Re: To pgsql-www@lists.postgresql.org
> New version attached. Not much change, the biggest one is perhaps that
> lists of badges are no longer rendered as html table, but in a CSS
> grid. Everything seems to work as it should now.
Well, attached now.
Christoph
Attachments:
[application/gzip] v5-0001-Introduce-contributor-badges.patch.gz (10.7K, ../../aeueqj3EjiKs3qac@msg.df7cb.de/2-v5-0001-Introduce-contributor-badges.patch.gz)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-04-27 11:46 Magnus Hagander <magnus@hagander.net>
parent: Christoph Berg <myon@debian.org>
1 sibling, 1 reply; 21+ messages in thread
From: Magnus Hagander @ 2026-04-27 11:46 UTC (permalink / raw)
To: Christoph Berg <myon@debian.org>; +Cc: pgsql-www@lists.postgresql.org, Andrey Borodin <x4mmm@yandex-team.ru>; Melanie Plageman <melanieplageman@gmail.com>; PostgreSQL Contributors <contributors@lists.postgresql.org>
On Fri, 24 Apr 2026 at 18:45, Christoph Berg <myon@debian.org> wrote:
> New version attached. Not much change, the biggest one is perhaps that
> lists of badges are no longer rendered as html table, but in a CSS
> grid. Everything seems to work as it should now.
>
> My plan is to commit this, minus the menu change in
> pgweb/util/contexts.py. That way, we can create the first badges and
> check if everything really works without things already being visible
> to users (unless they know the URLs).
>
>
Given the fairly substantial concerns raised ofer this during in-person
discussions at FOSDEM, please do *not* commit this until you have signoff
from those involved. (It might be that it's fine, but given the concerns
and discussions around it, let's make sure those are resolved).
//Magnus
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-04-28 13:06 Christoph Berg <myon@debian.org>
parent: Magnus Hagander <magnus@hagander.net>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-04-28 13:06 UTC (permalink / raw)
To: Magnus Hagander <magnus@hagander.net>; +Cc: pgsql-www@lists.postgresql.org, Andrey Borodin <x4mmm@yandex-team.ru>; Melanie Plageman <melanieplageman@gmail.com>; PostgreSQL Contributors <contributors@lists.postgresql.org>
Re: Magnus Hagander
> Given the fairly substantial concerns raised ofer this during in-person
> discussions at FOSDEM, please do *not* commit this until you have signoff
> from those involved. (It might be that it's fine, but given the concerns
> and discussions around it, let's make sure those are resolved).
I said in the other thread:
> The last result (from the lunch discussion in Brussels) was that we
> need handling of retired people from "permanent" badges like "Sysadmin
> Team". That's not included yet, but in the team, we concluded that
> instead of some complicated date range handling (perhaps that's what
> you remember as plenty?) we can just add a single boolean "retired"
> flag. We don't have that yet, but that's not a big blocker.
I've now implemented retiring from badges. Internally it's tracked by
filling in a date_retired field, but in order not to overcomplicate
the user interface, it's treated as a boolean. Retirees still show up
in badge listing, but are marked as retired.
Removing people from badges is now a two-stage process, retire them
and then remove them. To un-retire someone, add their email address
again (which NULLs the date_retired field).
I'm attaching that as a separate commit for better review. It would
get squashed before committing, of course.
I believe that addresses the concerns from Brussels, or was there
anything else?
Christoph
Attachments:
[application/gzip] v6-0001-Introduce-contributor-badges.patch.gz (10.8K, ../../afCwxreG0Ad882WL@msg.df7cb.de/2-v6-0001-Introduce-contributor-badges.patch.gz)
download
[application/gzip] v6-0002-Add-dates-on-badgeholders-so-people-can-be-retire.patch.gz (4.9K, ../../afCwxreG0Ad882WL@msg.df7cb.de/3-v6-0002-Add-dates-on-badgeholders-so-people-can-be-retire.patch.gz)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-07-29 12:51 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
0 siblings, 1 reply; 21+ messages in thread
From: Christoph Berg @ 2026-07-29 12:51 UTC (permalink / raw)
To: Magnus Hagander <magnus@hagander.net>; +Cc: pgsql-www@lists.postgresql.org, Andrey Borodin <x4mmm@yandex-team.ru>; Melanie Plageman <melanieplageman@gmail.com>; PostgreSQL Contributors <contributors@lists.postgresql.org>
Re: To Magnus Hagander
> I'm attaching that as a separate commit for better review. It would
> get squashed before committing, of course.
>
> I believe that addresses the concerns from Brussels, or was there
> anything else?
To finally pick this up again (my fault for slacking): We've talked in
Vancouver and the remaining concern was that the image URLs were
user-configurable free text.
I have now cherry-picked the ImageBinaryField feature from pgeu-system
and image upload is now fully self-contained in the pgweb database.
Attached is v7 of the patch, before squashing:
v7-0001-Cherry-pick-ImageBinaryField-and-related-from-pge.patch
v7-0002-Rewind-uploaded-file-before-reading-in-ImageBinar.patch
ImageBinaryField and a follow-up fix in there that I'll submit to
pgeu-system after review
v7-0003-Introduce-contributor-badges.patch
The original patch
v7-0004-Add-dates-on-badgeholders-so-people-can-be-retire.patch
The retirement feature
v7-0005-Use-ImageBinaryField-for-the-badges.patch
The switch to ImageBinaryField
v8 is the same after squashing 0003..0005. (With a better commit
message.)
v7-0001-Cherry-pick-ImageBinaryField-and-related-from-pge.patch
v7-0002-Rewind-uploaded-file-before-reading-in-ImageBinar.patch
v8-0003-Introduce-contributor-badges.patch
Christoph
Attachments:
[text/x-diff] v7-0001-Cherry-pick-ImageBinaryField-and-related-from-pge.patch (0B, ../../amn3WJa9Z7-ApNx_@msg.df7cb.de/2-v7-0001-Cherry-pick-ImageBinaryField-and-related-from-pge.patch)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
* Re: Introduce contributor badges
@ 2026-09-25 15:46 Christoph Berg <myon@debian.org>
parent: Christoph Berg <myon@debian.org>
0 siblings, 0 replies; 21+ messages in thread
From: Christoph Berg @ 2026-09-25 15:46 UTC (permalink / raw)
To: Magnus Hagander <magnus@hagander.net>; +Cc: pgsql-www@lists.postgresql.org, Andrey Borodin <x4mmm@yandex-team.ru>; Melanie Plageman <melanieplageman@gmail.com>; PostgreSQL Contributors <contributors@lists.postgresql.org>
Hi,
thanks Magnus for the in-depth patch review during the Lowlands PGday.
I'm attaching v11.
> I also note that patch 2 doesn't have the changes that went into pgeu. We should probably merge patches 1 and 2 as a single "import frm pgeu-system" and just grab the current head there, since it has progressed since. I can do that when the time comes.
I merged the two patches into 0001, but I don't see any new pgeu
changes that would affect this. What am I missing?
> Is there a reason that OneToOne change to the User model can't be done as a separate thing before this? In the interest of keeping things into easily reviewable pieces?
This is 0002 now.
> nitpick is you can save a bunch of html padding by not adding badges-cell to each cell and instead just use a css rule anchored in badges-grid I think, similar to how you're already doing it for the img just one step higher.
> and since some of these pages can get large quickly...
Done. Please check if I understood that correctly and did the correct
change, though :)
> Patch also contains 73aab7456 which has already been applied... So needs some rebasing.
That should have resolved itself.
> Queries for badges should avoid reading the imagedata in every query that doesn't need it. Using defer — probably by defining a default defer.
Fixed.
> UserModelMultipleChoiceField seems to be a bad name for something that checks Contributors, not Users?
Changed.
> Purging definitely needs to be more specific, we don't want to purge everything including all the images when a contributor is edited.
Also done. Since purges can be limited to specific pages by '...$', I
did not move any URLs around.
> (sprite discussion, will do that one in person )
I took a mental note of the problem - we'll revisit that once we have
too many badges (or better a bit before that).
> For the "list all people" view if we don't want to do pagination at this time can we at least put a temporary ceiling on it so we don't generate an absolutely ginormous page. as in only show the first 500 or something. It'll take time to reach that point, but it will get increasingly painful on the way there.
There's limits now, but I went with 2000. There are 1700 distinct
names mentioned in the release notes since PG10 and I think we would
hit 500 rather quickly.
Christoph
Attachments:
[application/gzip] v11-0001-Cherry-pick-ImageBinaryField-and-related-from-pg.patch.gz (3.6K, ../../araXTIuHpGxWIWHf@msg.df7cb.de/2-v11-0001-Cherry-pick-ImageBinaryField-and-related-from-pg.patch.gz)
download
[application/gzip] v11-0002-Turn-Contributor.user-into-a-OneToOneField.patch.gz (1.3K, ../../araXTIuHpGxWIWHf@msg.df7cb.de/3-v11-0002-Turn-Contributor.user-into-a-OneToOneField.patch.gz)
download
[application/gzip] v11-0003-Introduce-contributor-badges.patch.gz (11.9K, ../../araXTIuHpGxWIWHf@msg.df7cb.de/4-v11-0003-Introduce-contributor-badges.patch.gz)
download
[application/gzip] v11-0004-Make-contributor-badges-discoverable.patch.gz (1.8K, ../../araXTIuHpGxWIWHf@msg.df7cb.de/5-v11-0004-Make-contributor-badges-discoverable.patch.gz)
download
^ permalink raw reply [nested|flat] 21+ messages in thread
end of thread, other threads:[~2026-09-25 15:46 UTC | newest]
Thread overview: 21+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-01-05 22:03 [PATCH v2] Introduce contributor badges Christoph Berg <myon@debian.org>
2026-01-05 22:03 [PATCH v2] Introduce contributor badges Christoph Berg <myon@debian.org>
2026-01-19 16:28 Introduce contributor badges Christoph Berg <myon@debian.org>
2026-01-20 11:56 ` Andrey Borodin <x4mmm@yandex-team.ru>
2026-01-20 12:11 ` Christoph Berg <myon@debian.org>
2026-01-20 12:52 ` Christoph Berg <myon@debian.org>
2026-01-23 10:14 ` Christoph Berg <myon@debian.org>
2026-01-23 19:03 ` Christoph Berg <myon@debian.org>
2026-04-24 16:45 ` Christoph Berg <myon@debian.org>
2026-04-24 16:47 ` Christoph Berg <myon@debian.org>
2026-04-27 11:46 ` Magnus Hagander <magnus@hagander.net>
2026-04-28 13:06 ` Christoph Berg <myon@debian.org>
2026-07-29 12:51 ` Christoph Berg <myon@debian.org>
2026-09-25 15:46 ` Christoph Berg <myon@debian.org>
2026-04-24 16:16 [PATCH v8 3/3] Introduce contributor badges Christoph Berg <myon@debian.org>
2026-04-24 16:16 [PATCH v7 3/5] Introduce contributor badges Christoph Berg <myon@debian.org>
2026-04-24 16:16 [PATCH v8 3/3] Introduce contributor badges Christoph Berg <myon@debian.org>
2026-04-24 16:16 [PATCH v7 3/5] Introduce contributor badges Christoph Berg <myon@debian.org>
2026-04-24 16:16 [PATCH v8 3/3] Introduce contributor badges Christoph Berg <myon@debian.org>
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox