From 7c4b1a45b75a1d24bc049b1a8c1b9943646a73f2 Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Mon, 5 Jan 2026 23:03:34 +0100
Subject: [PATCH v2] Introduce contributor badges

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

