diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 76450ed9e6fdccb574a17ca23fde70e7ed0dbe7b..c57f93ce9a44e03b865a77db1500a5622715e0f5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -48,6 +48,7 @@ Changed * Login and authorization pages for OAuth2/OpenID Connect now indicate that the user is in progress to authorize an external application. * Tables can be scrolled horizontally. +* Overhauled person detail page `2.5`_ – 2022-01-02 ------------------- diff --git a/aleksis/core/migrations/0033_update_photo_avatar.py b/aleksis/core/migrations/0033_update_photo_avatar.py index 12dfa4fbc50a0119ed0e7fec0ce73a5e5cbc0f45..e20c6ee6bff00581fa7f586b2e9e9888ce1dfd1d 100644 --- a/aleksis/core/migrations/0033_update_photo_avatar.py +++ b/aleksis/core/migrations/0033_update_photo_avatar.py @@ -30,4 +30,12 @@ class Migration(migrations.Migration): name='photo', field=models.ImageField(blank=True, help_text='This is an official photo, used for official documents and for internal use cases.', null=True, upload_to='', verbose_name='Photo'), ), + migrations.AlterModelOptions( + name='globalpermissions', + options={'default_permissions': (), 'managed': False, 'permissions': (('view_system_status', 'Can view system status'), ('manage_data', 'Can manage data'), ('impersonate', 'Can impersonate'), ('search', 'Can use search'), ('change_site_preferences', 'Can change site preferences'), ('change_person_preferences', 'Can change person preferences'), ('change_group_preferences', 'Can change group preferences'), ('test_pdf', 'Can test PDF generation'))}, + ), + migrations.AlterModelOptions( + name='person', + options={'ordering': ['last_name', 'first_name'], 'permissions': (('view_address', 'Can view address'), ('view_contact_details', 'Can view contact details'), ('view_photo', 'Can view photo'), ('view_avatar', 'Can view avatar image'), ('view_person_groups', 'Can view persons groups'), ('view_personal_details', 'Can view personal details')), 'verbose_name': 'Person', 'verbose_name_plural': 'Persons'}, + ), ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index bd26f4f4edbc04bbef679ae92311b1215138f3ad..9c78211eac9dfabe6e1d0b8de8e2034f0dc9da2c 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -155,6 +155,7 @@ class Person(ExtensibleModel): ("view_address", _("Can view address")), ("view_contact_details", _("Can view contact details")), ("view_photo", _("Can view photo")), + ("view_avatar", _("Can view avatar image")), ("view_person_groups", _("Can view persons groups")), ("view_personal_details", _("Can view personal details")), ) @@ -311,6 +312,10 @@ class Person(ExtensibleModel): """Return the count of unread notifications for this person.""" return self.unread_notifications.count() + @property + def initials(self): + return f"{self.first_name[0]}{self.last_name[0]}".upper() + user_info_tracker = FieldTracker(fields=("first_name", "last_name", "email")) @property diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index 9fc08709ec48e74f0a3801262d29a527764ac323..f989b7b0814aca40b9604512a4ac69690906f2e4 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -58,6 +58,12 @@ view_photo_predicate = has_person & ( ) rules.add_perm("core.view_photo_rule", view_photo_predicate) +# View person avatar image +view_avatar_predicate = has_person & ( + has_global_perm("core.view_avatar") | has_object_perm("core.view_avatar") | is_current_person +) +rules.add_perm("core.view_avatar_rule", view_avatar_predicate) + # View persons groups view_groups_predicate = has_person & ( has_global_perm("core.view_person_groups") diff --git a/aleksis/core/static/img/hero.svg b/aleksis/core/static/img/hero.svg new file mode 100644 index 0000000000000000000000000000000000000000..e11b51689d359a713f0bf5482a91c388a8632b60 --- /dev/null +++ b/aleksis/core/static/img/hero.svg @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="1080px" width="1920px"> + <defs> + <pattern id="doodad" width="92.38" height="80" viewBox="0 0 34.64101615137755 30" patternUnits="userSpaceOnUse" patternTransform="rotate(134)"> + <path d="M-20-20h200v200h-200M33.77 25.5L25.98 21L18.19 25.5L18.19 34.5L25.98 39L33.77 34.5zM16.45 25.5L8.66 21L0.87 25.5L0.87 34.5L8.66 39L16.45 34.5zM7.79 10.5L0 6L-7.79 10.5L-7.79 19.5L0 24L7.79 19.5zM16.45-4.5L8.66-9L0.87-4.5L0.87 4.5L8.66 9L16.45 4.5zM33.77-4.5L25.98-9L18.19-4.5L18.19 4.5L25.98 9L33.77 4.5zM42.43 10.5L34.64 6L26.85 10.5L26.85 19.5L34.64 24L42.43 19.5zM25.11 10.5L17.32 6L9.53 10.5L9.53 19.5L17.32 24L25.11 19.5z" fill="#2222"/> + <path d="M-20-20h200v200h-200M24.21 25.25L15.98 20.5L7.75 25.25L7.75 34.75L15.98 39.5L24.21 34.75zM6.89 25.25L-1.34 20.5L-9.57 25.25L-9.57 34.75L-1.34 39.5L6.89 34.75zM-1.77 10.25L-10 5.5L-18.23 10.25L-18.23 19.75L-10 24.5L-1.77 19.75zM6.89-4.75L-1.34-9.5L-9.57-4.75L-9.57 4.75L-1.34 9.5L6.89 4.75zM24.21-4.75L15.98-9.5L7.75-4.75L7.75 4.75L15.98 9.5L24.21 4.75zM32.87 10.25L24.64 5.5L16.41 10.25L16.41 19.75L24.64 24.5L32.87 19.75zM41.53 25.25L33.3 20.5L25.07 25.25L25.07 34.75L33.3 39.5L41.53 34.75zM15.55 40.25L7.32 35.5L-0.91 40.25L-0.91 49.75L7.32 54.5L15.55 49.75zM-10.43 25.25L-18.66 20.5L-26.89 25.25L-26.89 34.75L-18.66 39.5L-10.43 34.75zM-10.43-4.75L-18.66-9.5L-26.89-4.75L-26.89 4.75L-18.66 9.5L-10.43 4.75zM15.55-19.75L7.32-24.5L-0.91-19.75L-0.91-10.25L7.32-5.5L15.55-10.25zM41.53-4.75L33.3-9.5L25.07-4.75L25.07 4.75L33.3 9.5L41.53 4.75zM32.87 40.25L24.64 35.5L16.41 40.25L16.41 49.75L24.64 54.5L32.87 49.75zM-1.77 40.25L-10 35.5L-18.23 40.25L-18.23 49.75L-10 54.5L-1.77 49.75zM-19.09 10.25L-27.32 5.5L-35.55 10.25L-35.55 19.75L-27.32 24.5L-19.09 19.75zM-1.77-19.75L-10-24.5L-18.23-19.75L-18.23-10.25L-10-5.5L-1.77-10.25zM32.87-19.75L24.64-24.5L16.41-19.75L16.41-10.25L24.64-5.5L32.87-10.25zM50.19 10.25L41.96 5.5L33.73 10.25L33.73 19.75L41.96 24.5L50.19 19.75zM15.55 10.25L7.32 5.5L-0.91 10.25L-0.91 19.75L7.32 24.5L15.55 19.75z" fill="#7777"/> + </pattern> + </defs> + <rect fill="url(#doodad)" height="200%" width="200%"/> +</svg> diff --git a/aleksis/core/static/js/main.js b/aleksis/core/static/js/main.js index 119103b5ea3885f290410a42758a5f672caffdc4..0610cbae2c6904572ae4b8b6714a12b67382c133 100644 --- a/aleksis/core/static/js/main.js +++ b/aleksis/core/static/js/main.js @@ -84,6 +84,9 @@ $(document).ready(function () { // Initialize Modals [MAT] $('.modal').modal(); + // Initialize image boxes [Materialize] + $('.materialboxed').materialbox(); + // Intialize Tabs [Materialize] $('.tabs').tabs(); diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss index bd8f2e4d621a8dd89a897b14ef25d68c2a88395c..9d3a7f5609c20400d3addbd86e4e0e6efe965393 100644 --- a/aleksis/core/static/public/style.scss +++ b/aleksis/core/static/public/style.scss @@ -96,8 +96,11 @@ header, main, footer { /* MAIN */ /********/ +$main-padding-lr: 20px; +$main-padding-tb: 10px; + main { - padding: 10px 20px; + padding: $main-padding-tb $main-padding-lr; flex: 1 0 auto; } @@ -809,6 +812,112 @@ main figure.alert { margin-bottom: 8px; } +/* Person overview */ +$person-logo-size: 20vh; + +.clip-circle:not(.active) { + width: $person-logo-size; + height: $person-logo-size; + background: #f9f9f9; + border-radius: 50%; + + & img { + border-radius: 50%; + width: 20vh; + height: 20vh; + object-fit: cover; + } +} + +.clip-circle.no-image, .clip-circle.no-image>i.material-icons { + font-size: calc(#{$person-logo-size} * 0.5); + color: #6f6f6f; + background: #f2f2f2; + line-height: $person-logo-size; + width: $person-logo-size; + text-align: center; + user-select: none; + cursor: default; + border-radius: 50%; +} + +#hero-bg { + position: absolute; + width: calc(100% + #{$main-padding-lr}); + height: 30vh; + left: -$main-padding-lr; + top: 0; + overflow: hidden; + background-color: lighten($primary-color, 30%); + z-index: -1; + box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14) inset, + 0 1px 10px 0 rgba(0, 0, 0, 0.12) inset, + 0 2px 4px -1px rgba(0, 0, 0, 0.3) inset; +} + +.person-buttons { + display: flex; + flex-direction: column; + gap: 6px; + width: max-content; + position: absolute; + right: $main-padding-lr; + margin: 0; + align-items: end; + & a { + -webkit-transition: width 0.5s 0s ease; + -moz-transition: width 0.5s 0s ease; + -o-transition: width 0.5s 0s ease; + transition: width 0.5s 0s ease; + + &:hover { + width: max-content; + } + } +} + +@media (pointer: fine) { + .person-buttons a { + width: 50px; + } +} + +.person-container { + margin: calc(30vh - #{$main-padding-tb} - #{$navbar-height} - 10vh) 0 0 0; + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: space-around; + flex-direction: column; +} + +.person-collection .collection-item.avatar { + display: flex; + align-items: center; + + & img.circle { + object-fit: cover; + } +} + +.materialboxed { + &:not(.active) { + opacity: 100% !important; + & > img:hover { + opacity: 80%; + } + } + + &.active { + box-shadow: none; + + & > img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + .application-circle { border-radius: 50%; width: 20vh; diff --git a/aleksis/core/templates/core/partials/hero_background.html b/aleksis/core/templates/core/partials/hero_background.html new file mode 100644 index 0000000000000000000000000000000000000000..33a4890586a18f5f0b5e486d6fb750707ac805e8 --- /dev/null +++ b/aleksis/core/templates/core/partials/hero_background.html @@ -0,0 +1,2 @@ +{% load static %} +<div id="hero-bg" style="background-image: url('{% static "img/hero.svg" %}'); background-size: 3840px"></div> diff --git a/aleksis/core/templates/core/person/collection.html b/aleksis/core/templates/core/person/collection.html index c23fa2360c00977485a9d32373ae826d7fe66b6d..fb8518e97e3e8d770260c8e6b004df420d8efa15 100644 --- a/aleksis/core/templates/core/person/collection.html +++ b/aleksis/core/templates/core/person/collection.html @@ -1,7 +1,15 @@ -<div class="collection"> +{% load rules %} + +<div class="collection person-collection"> {% for person in persons %} - <a class="collection-item" href="{% url "person_by_id" person.pk %}"> - <i class="material-icons left">person</i> + <a class="collection-item avatar waves-effect" href="{% url "person_by_id" person.pk %}"> + {% has_perm 'core.view_photo_rule' user person as can_view_photo %} + {% if person.photo and can_view_photo %} + <img class="circle" src="{{ person.photo.url }}" + alt="{{ person.first_name }} {{ person.last_name }}"/> + {% else %} + <i class="material-icons materialize-circle">person</i> + {% endif %} {{ person }} </a> {% endfor %} diff --git a/aleksis/core/templates/core/person/full.html b/aleksis/core/templates/core/person/full.html index 4a2e68a09517d1888f708b8ece3f757c6b1d8b1f..749ba164229845057c965e4efd3e8d0fb9423f32 100644 --- a/aleksis/core/templates/core/person/full.html +++ b/aleksis/core/templates/core/person/full.html @@ -6,9 +6,9 @@ {% load render_table from django_tables2 %} {% block browser_title %}{{ person.first_name }} {{ person.last_name }}{% endblock %} +{% block no_page_title%}{% endblock %} {% block content %} - <h1>{{ person.first_name }} {{ person.last_name }}</h1> {% has_perm 'core.edit_person_rule' user person as can_change_person %} {% has_perm 'core.change_person_preferences_rule' user person as can_change_person_preferences %} @@ -16,99 +16,175 @@ {% has_perm "core.impersonate_rule" user person as can_impersonate %} {% has_perm "core.can_invite" user person as can_invite %} - {% if can_change_person or can_change_person_preferences or can_delete_person or can_impersonate %} - <p> - {% if can_change_person %} - <a href="{% url 'edit_person_by_id' person.id %}" class="btn waves-effect waves-light"> - <i class="material-icons left">edit</i> - {% trans "Edit" %} - </a> - {% endif %} + {% include "core/partials/hero_background.html" %} - {% if can_delete_person %} - <a href="{% url 'delete_person_by_id' person.id %}" class="btn waves-effect waves-light red"> - <i class="material-icons left">delete</i> - {% trans "Delete" %} - </a> - {% endif %} + {% if can_change_person or can_change_person_preferences or can_delete_person or can_impersonate or can_invite %} + <p class="person-buttons hide-on-med-and-down"> + {% if can_change_person %} + <a href="{% url 'edit_person_by_id' person.id %}" class="btn waves-effect waves-light"> + <i class="material-icons left">edit</i> + {% trans "Edit" %} + </a> + {% endif %} - {% if can_change_person_preferences %} - <a href="{% url "preferences_person" person.id %}" class="btn waves-effect waves-light"> - <i class="material-icons left">settings</i> - {% trans "Change preferences" %} - </a> - {% endif %} + {% if can_delete_person %} + <a href="{% url 'delete_person_by_id' person.id %}" class="btn waves-effect waves-light red"> + <i class="material-icons left">delete</i> + {% trans "Delete" %} + </a> + {% endif %} - {% if can_impersonate and person.user %} - <a href="{% url "impersonate-start" person.user.id %}" class="btn waves-effect waves-light"> - <i class="material-icons left">portrait</i> - {% trans "Impersonate" %} - </a> - {% endif %} - {% if can_invite and not person.user %} - <a href="{% url "invite_person_by_id" person.id %}" class="btn waves-effect waves-light"> - <i class="material-icons left">card_giftcard</i> - {% trans "Invite user" %} - </a> + {% if can_change_person_preferences %} + <a href="{% url 'preferences_person' person.id %}" class="btn waves-effect waves-light"> + <i class="material-icons left">settings</i> + {% trans "Change preferences" %} + </a> + {% endif %} + + {% if can_impersonate and person.user %} + <a href="{% url 'impersonate-start' person.user.id %}" class="btn waves-effect waves-light"> + <i class="material-icons left">portrait</i> + {% trans "Impersonate" %} + </a> + {% endif %} + + {% if can_invite and not person.user %} + <a href="{% url "invite_person_by_id" person.id %}" class="btn waves-effect waves-light"> + <i class="material-icons left">card_giftcard</i> + {% trans "Invite user" %} + </a> + {% endif %} + </p> {% endif %} - </p> - {% endif %} - <h2>{% blocktrans %}Contact details{% endblocktrans %}</h2> - <div class="row"> - <div class="col s12 m4"> - {% has_perm 'core.view_photo_rule' user person as can_view_photo %} - {% if person.photo and can_view_photo %} - <img class="person-img" src="{{ person.photo.url }}" - alt="{{ person.first_name }} {{ person.last_name }}"/> + <header class="person-container"> + <div class="image-wrapper"> + {% has_perm 'core.view_avatar_rule' user person as can_view_avatar %} + {% if person.avatar and can_view_avatar %} + <div class="clip-circle materialboxed z-depth-2"> + <img class="hundred-percent" src="{{ person.avatar.url }}" + alt="{{ person.first_name }} {{ person.last_name }}"/> + </div> + {% else %} - <img class="person-img" src="{% static 'img/fallback.png' %}" - alt="{{ person.first_name }} {{ person.last_name }}"/> + + <div class="clip-circle no-image z-depth-2"> + {{ person.initials }} + </div> {% endif %} </div> - <div class="col s12 m8"> - <table class="responsive-table highlight"> - <tr> - <td rowspan="6"> + <h1> + {{ person.first_name }} {{ person.last_name }} + {% if person.user %} + <small class="grey-text">{{ person.user.username }}</small> + {% endif %} + </h1> + </header> - </td> + <div class="row"> + {% if person.description %} + <div class="col s12"> + <p class="container center-align"> + {{ person.description }} + </p> + </div> + {% endif %} + {% if can_change_person or can_change_person_preferences or can_delete_person or can_impersonate or can_invite %} + <div class="col s12 hide-on-large-only"> + <div class="collection"> + {% if can_change_person %} + <a href="{% url 'edit_person_by_id' person.id %}" class="collection-item waves-effect waves-dark"> + <i class="material-icons left">edit</i> + {% trans "Edit" %} + </a> + {% endif %} + + {% if can_delete_person %} + <a href="{% url 'delete_person_by_id' person.id %}" class="collection-item waves-effect waves-red red-text"> + <i class="material-icons left">delete</i> + {% trans "Delete" %} + </a> + {% endif %} + + {% if can_change_person_preferences %} + <a href="{% url 'preferences_person' person.id %}" class="collection-item waves-effect waves-dark"> + <i class="material-icons left">settings</i> + {% trans "Change preferences" %} + </a> + {% endif %} + + {% if can_impersonate and person.user %} + <a href="{% url 'impersonate-start' person.user.id %}" class="collection-item waves-effect waves-dark"> + <i class="material-icons left">portrait</i> + {% trans "Impersonate" %} + </a> + {% endif %} + + {% if can_invite and not person.user %} + <a href="{% url "invite_person_by_id" person.id %}" class="collection-item waves-effect waves-light"> + <i class="material-icons left">card_giftcard</i> + {% trans "Invite user" %} + </a> + {% endif %} + </div> + </div> + {% endif %} + <div class="col s12 l4"> + <h2>{% blocktrans %}Contact details{% endblocktrans %}</h2> + <div class="card-panel"> + <table class="highlight"> + <tr> <td> <i class="material-icons small">person</i> </td> - <td>{{ person.first_name }}</td> - <td>{{ person.additional_name }}</td> - <td>{{ person.last_name }}</td> + <td>{{ person.first_name }} {{ person.additional_name }} {{ person.last_name }}</td> </tr> <tr> <td> <i class="material-icons small">face</i> </td> - <td colspan="3">{{ person.get_sex_display }}</td> + <td>{% firstof person.get_sex_display "–" %}</td> </tr> {% has_perm 'core.view_address_rule' user person as can_view_address %} {% if can_view_address %} <tr> - <td> + <td rowspan="2"> <i class="material-icons small">home</i> </td> - <td colspan="2">{{ person.street }} {{ person.housenumber }}</td> - <td colspan="2">{{ person.postal_code }} {{ person.place }}</td> + <td>{% firstof person.street "–" %} {{ person.housenumber }}</td> + </tr> + <tr> + <td>{{ person.postal_code }} {% firstof person.place "–" %}</td> </tr> {% endif %} {% has_perm 'core.view_contact_details_rule' user person as can_view_contact_details %} {% if can_view_contact_details %} <tr> - <td> + <td rowspan="2"> <i class="material-icons small">phone</i> </td> - <td>{{ person.phone_number }}</td> - <td>{{ person.mobile_number }}</td> + <td> + <a href="tel:{{ person.phone_number }}">{{ person.phone_number }}</a> + <small>({% trans "home number" %})</small> + </td> + </tr> + <tr> + <td> + <a href="tel:{{ person.phone_number }}">{{ person.mobile_number }}</a> + <small>({% trans "mobile number" %})</small> + </td> </tr> <tr> <td> <i class="material-icons small">email</i> </td> - <td colspan="3">{{ person.email }}</td> + <td> + {% if person.email %} + <a href="mailto:{{ person.email }}">{{ person.email }}</a> + {% else %} + – + {% endif %} + </td> </tr> {% endif %} {% has_perm 'core.view_personal_details_rule' user person as can_view_personal_details %} @@ -117,39 +193,63 @@ <td> <i class="material-icons small">cake</i> </td> - <td colspan="2">{{ person.date_of_birth|date }}</td> - <td colspan="2">{{ person.place_of_birth }}</td> + <td> + <time datetime="{{ person.date_of_birth|date:'c' }}">{{ person.date_of_birth|date }}</time> + {% firstof person.place_of_birth "–" %} + </td> </tr> {% endif %} </table> - </div> - {% if person.description %} - <div class="col s12 m12"> - <h2>{% trans "Description" %}</h2> - <p> - {{ person.description }} - </p> </div> - {% endif %} - </div> + {% has_perm 'core.view_photo_rule' user person as can_view_photo %} + {% if person.photo and can_view_photo %} + <div class="card"> + <div class="card-image"> + <img src="{{ person.photo.url }}" alt="{{ person.first_name }} {{ person.last_name }}" class="materialboxed"> + <span class="card-title">{{ person.first_name }} {{ person.last_name }}</span> + </div> + </div> - {% if person.children.all and can_view_personal_details %} - <div class="col s12 m12"> - <h2>{% trans "Children" %}</h2> - {% include "core/person/collection.html" with persons=person.children.all %} + {% else %} + <div class="card-panel"> + <i class="material-icons left">image_not_supported</i> + {% trans "This person didn't upload a personal photo." %} + </div> + {% endif %} </div> - {% endif %} - {% if person.guardians.all and can_view_personal_details %} - <div class="col s12 m12"> - <h2>{% trans "Guardians / Parents" %}</h2> - {% include "core/person/collection.html" with persons=person.guardians.all %} + {% if person.children.all or person.guardians.all and can_view_personal_details %} + <div class="col s12 m6 l4"> + {% if person.children.all and can_view_personal_details %} + <h2>{% trans "Children" %}</h2> + <div class="card-panel"> + {% include "core/person/collection.html" with persons=person.children.all %} + </div> + {% endif %} + + {% if person.guardians.all and can_view_personal_details %} + <h2>{% trans "Guardians / Parents" %}</h2> + <div class="card-panel"> + {% include "core/person/collection.html" with persons=person.guardians.all %} + </div> + {% endif %} </div> - {% endif %} + {% endif %} - {% has_perm 'core.view_person_groups_rule' user person as can_view_groups %} - {% if can_view_groups %} - <h2>{% blocktrans %}Groups{% endblocktrans %}</h2> - {% render_table groups_table %} - {% endif %} + {% has_perm 'core.view_person_groups_rule' user person as can_view_groups %} + {% if can_view_groups and groups %} + <div class="col s12 m6 l4"> + <h2>{% blocktrans %}Groups{% endblocktrans %}</h2> + <div class="card-panel"> + <div class="collection"> + {% for group in groups %} + <a href="{{ group.get_absolute_url }}" class="collection-item"> + {{ group.name }} ({{ group.school_term }}) + </a> + {% endfor %} + </div> + </div> + </div> + {% endif %} + </div> {% endblock %} diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 49ef723df8d5fdb27d810c8668d9ec3559d9272e..33af719a6a2dd22471740b2dc5dbc4ab357293bd 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -326,12 +326,7 @@ def person(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: context["person"] = person # Get groups where person is member of - groups = Group.objects.filter(members=person) - - # Build table - groups_table = GroupsTable(groups) - RequestConfig(request).configure(groups_table) - context["groups_table"] = groups_table + context["groups"] = person.member_of.all() return render(request, "core/person/full.html", context)