Prompt file imported from Jesterboxboy/mahjong-portal (
.github/prompts/improvements.prompt.md). Copyright stays with the author.
Improvement 1 ✓ DONE
Change Tournament registration mask
Add a field for email adress and make it mandatory Make the telephone field optional remove the additional contact
Implementation
tournament/models.py: Addedemail = models.EmailField(...)(mandatory) toTournamentRegistration; madephoneoptional (null=True, blank=True)tournament/forms.py: UpdatedTournamentRegistrationForm.Meta.fields— addedemail, removedadditional_contacttournament/admin.py: AddedemailtoTournamentRegistrationAdmin.list_displaytournament/migrations/0061_add_email_to_registration_make_phone_optional.py: Migration created and applied
Improvement 2 ✓ DONE
Tournament announcement page — Info tab, country field, GDPR PDF, card styling
Requirements
- Add an optional (later: mandatory) Country field to tournament registration
- Add a "Tournament Info" tab alongside "Registration" on the announcement page, showing date, address, itinerary, lunch options, contact info
- Make it possible to attach a GDPR/data-protection PDF document, linked above the consent checkbox
- Style the registration form similar to the settings page (card-based layout)
- Tournament Info tab fields must be bilingual (German + English), edited in Django admin
Implementation
tournament/models.py
Tournament: addedvenue_address,schedule,lunch_options,contact_info(allTextField, nullable) andgdpr_document(FileField, uploads totournament/gdpr/)TournamentRegistration: addedregistration_country(CharField,null=True, mandatory at form level viablanknot set)OnlineTournamentRegistration: sameregistration_countryfield
tournament/translation.py
- Added
venue_address,schedule,lunch_options,contact_infotoTournamentTranslationOptions→ creates_de/_enDB columns;TabbedTranslationAdminshows language tabs in admin
tournament/forms.py
TournamentRegistrationForm.Meta.fields: addedregistration_country(betweenfirst_nameandcity)OnlineTournamentRegistrationForm.Meta.fields: same- Both forms iterate fields individually in the template to inject the GDPR link before the consent checkbox
tournament/admin.py
TournamentAdminnow inheritsTabbedTranslationAdmin(frommodeltranslation.admin) → DE/EN tabs forvenue_address,schedule,lunch_options,contact_info- New fieldsets:
"Tournament Info Tab"(the 4 info fields) and"GDPR"(the document upload) TournamentRegistrationAdminandOnlineTournamentRegistrationAdminshowregistration_countryin list
mahjong_portal/settings.py
- Added
MEDIA_URL = "/media/"andMEDIA_ROOT = os.path.join(BASE_DIR, "media")to support file uploads
mahjong_portal/urls.py
- Added
servefor/media/path whenDEBUG=True
templates/tournament/announcement.html
- Full rewrite with Bootstrap 5 tab structure: "Registration" tab + optional "Tournament Info" tab
- "Tournament Info" tab only appears when at least one info field is filled; shows cards per section (Venue, Schedule, Lunch, Contact) with coloured headers
- Registration form and participants list wrapped in Bootstrap cards (matching settings page style)
- Fields rendered individually (
{% for field in form %}) so GDPR PDF link can be injected before the consent checkbox - "Registered players" count fixed to use
online_tournament_registrationsfor Pantheon non-online tournaments
Migrations
0062_improvement2_info_fields: adds all new Tournament and registration fields0063_info_fields_translations: adds_de/_encolumns for the 4 info fields
Improvement 3 ✓ DONE
Add the same fields that are in Tournament Info Tab in both languages to the Turnieranmeldung, also with tabs. Change telephone of organizer to optional, but change "Zusätzlicher Kontakt des Veranstalters" to mandatory email field an move above phone field. Also make judge fields non mandatory and do the same with phone and additional contact fields as with the organizers. Options in type of tournament should be ema/other/online and match to TOurnament type. Online should also set tournament game type to online.
Implementation
tournament/models.py — TournamentApplication changes
tournament_type:PositiveSmallIntegerField[CRR/RR/EMA/OTHER] →CharField(max_length=10)with[["ema","EMA"],["other","Other"],["online","Online"]], default"ema"— matchesTournament.TOURNAMENT_TYPES- Added
country(CharField, optional) to carry country name into the Tournament (used by Improvement 4 action) - Added 8 bilingual info fields (all
TextField, nullable):venue_address_de/en,schedule_de/en,lunch_options_de/en,contact_info_de/en organizer_phone: made optional (null=True, blank=True)organizer_additional_contact→ renamed toorganizer_email(EmailField,null=True; noblank=True→ form-required), moved above phone in templatereferee_name: optional (null=True, blank=True)referee_phone: optional (null=True, blank=True)referee_additional_contact→ renamed toreferee_email(EmailField,null=True, blank=True— optional)
tournament/forms.py
TournamentApplicationForm: addedrequired_css_class = "required-field"; changedexclude = []→exclude = ["tournament_admin_user"]
templates/tournament/application.html
- Added
countryfield to "Main info" section - New "Tournament info (bilingual)" section with Bootstrap 5 DE/EN tabs containing
venue_address,schedule,lunch_options,contact_infopairs - "Organizer" section:
organizer_emailplaced aboveorganizer_phone; removed oldorganizer_additional_contact - "Referee" section:
referee_emailreplacesreferee_additional_contact
tournament/migrations/0064_tournament_application_improvements.py
RemoveField/AddFieldfortournament_type(type change),AlterFieldfor organizer/referee fields,RenameFieldfor _contact→_email, 8AddFieldfor bilingual info columns,AddFieldforcountry
Improvement 4 ✓ DONE
Add an option to tournament application in django admin under ournament/tournamentapplication/ that allows to create a tournament entry from a tournament application entry using all the information in the fields. If necessary change fields from Application so they match the tournament model, but not the other way round. The option should be in the dropdown of Aktion and should create a tournament draft and redirect to the tournament edit page.
Implementation
tournament/admin.py
- Added helper
_parse_date(value)that tries multiple date formats (dd.mm.yyyy,yyyy-mm-dd, etc.) - Added admin action
create_tournament_from_application(modeladmin, request, queryset):- Enforces single-item selection
- Resolves
Countryobject by name or code fromapp.country; falls back toCountry.objects.first() - Parses
start_date/end_datefrom the CharField; uses today as fallback forend_date - Generates a unique
slugfrom the tournament name (appends-Nsuffix on collision) - Sets
tournament_games_type = "online"whentournament_type == "online" - Maps all bilingual info fields directly; falls back
venue_address_de→ legacyaddressfield - Derives
contact_info_defrom organizer name/email/phone if explicit field is blank - Creates
Tournamentwithis_upcoming=True(draft); saves - Shows success message and redirects to the new Tournament's admin change page
TournamentApplicationAdmin.actions = [create_tournament_from_application]
Improvement 5 ✓ DONE
http://localhost:8060/admin/rating/rating/2/change/ still shows de and en names, please remove these and all dependencies so its only showing one name field
Implementation
rating/translation.py
- Removed
translator.register(Rating, ...)andtranslator.register(ExternalRating, ...)— both models now use plainname/descriptionwithout language variants.
rating/admin.py
RatingForm: changedexclude = ["name", "description"]→fields = ["name", "slug", "description", "type", "order"]ExternalRatingForm: changedexclude = ["name", "description"]→fields = ["name", "slug", "description", "type", "order", "is_hidden"]
rating/migrations/0022_remove_rating_translation_fields.py
RunSQLto copyname_de → nameanddescription_de → description(German was the default language)RemoveFieldforname_de,name_en,description_de,description_enon bothratingandexternalratingtables
Improvement 6 ✓ DONE
i want the EMA ranking to be populated by the information from http://mahjong-europe.org/ranking/Country/AUT_RCR.html with the fields first name, last name, Total, EMA Ranking, but sorted by Total instead of the ranking calculator based on all ema tournaments in the system, but so that calling 40 1 * * * python /app/manage.py rating_calculate ema in the crontab still updates these values
use the scraper from the austrian_ranking app for that.
Implementation
austria_ranking/scraper.py
- Added
scrape_at_ranking()function: fetchesAUT_RCR.html, parsesp[0](EMA global rank),p[2](EMA ID),p[3](last name),p[4](first name),p[6](total points) - Returns list of
{ema_id, first_name, last_name, ema_rank, total_points}sorted bytotal_pointsdescending
rating/calculation/ema.py
- Completely rewrote
RatingEMACalculation— no longer inherits fromRatingRRCalculation/RatingDatesMixin USE_SCRAPER = Trueclass attribute signals the command to use today-only refreshcalculate_players_rating_rank(rating, rating_date): callsscrape_at_ranking(), matches players byema_id, createsRatingResultrows withscore=total_points,place= rank by total,rating_calculation= EMA global rank string
rating/management/commands/rating_calculate.py
- Added
USE_SCRAPERcheck after building the calculator: if True, skips tournament date scanning and instead clears+recreatesRatingDate/RatingResultfor today, then callscalculate_players_rating_rank from_zeroflag still works: clears all historical data before refreshing
Improvement 7 ✓ DONE
Run a nightly calculation for every QuotaEvent that is current in the Austrian rankings. There are already "Run Calculation" buttons in http://localhost:8060/admin/austria_ranking/quotaevent/ — reuse that logic.
Implementation
austria_ranking/management/commands/calculate_austrian_ranking.py (new)
- Queries
QuotaEvent.objects.filter(is_current=True) - For each: calls
scraper.run_full_scrape(period)thencalculator.rank_players_for_period(period)and updatesperiod.calculated_at— identical to what the admin "Run Calculation" button does
docker/django/crontab
- Added
50 1 * * * python /app/manage.py calculate_austrian_ranking(runs at 01:50, after the EMA rating job)
Improvement 8 ✓ DONE
move functionality of the run calculation buttons in austria_ranking/quotaevents to the action dropdown menu instead of buttons as it is now.
Implementation
austria_ranking/admin.py
- Removed
run_buttonmethod and"run_button"fromlist_display - Removed
from django.urls import reverseandfrom django.utils.html import format_htmlimports - Added module-level action function
run_ranking_calculation(modeladmin, request, queryset): iterates the selected queryset, callsscraper.run_full_scrape(period)+calculator.rank_players_for_period(period), updatescalculated_at, and shows a success message - Added
actions = [run_ranking_calculation]toQuotaEventAdmin
mahjong_portal/urls.py
- Removed
from austria_ranking.views import run_ranking_calculationimport - Removed
url(r"^admin/austria-ranking/(?P<pk>\d+)/run/$", ...)URL entry
austria_ranking/views.py
- Removed the now-unused
run_ranking_calculationview and all its imports; file reduced to a single encoding comment
templates/website/rangliste.html
- Removed the staff-only "Calculate now" link that referenced the deleted
austria_ranking_runURL
Improvement 9 ✓ DONE
- Add an additional app "Vereinsmitglieder" that tracks active club members and fees per year. Create a model Mitgliedschaftsbeitrag that has the following fields. year, foreign key top player, but also display the player name and first name in the table view. Allow filtering by year. So if a player has payed his yearly fee i will add him to this list.
Implementation
vereinsmitglieder/ (new app)
models.py:Mitgliedschaftsbeitragwithyear = PositiveIntegerField()andplayer = ForeignKey('player.Player', related_name='membership_fees');unique_together = [['year', 'player']]; default ordering by-year, last_name, first_nameadmin.py:MitgliedschaftsbeitragAdminwithlist_display = ['year', 'player_last_name', 'player_first_name'],list_filter = ['year'],raw_id_fields = ['player'],search_fieldson player name;player_last_nameandplayer_first_nameare display methods withadmin_order_fieldapps.py,__init__.py,migrations/__init__.pycreatedmigrations/0001_initial.py: manually crafted (nomakemigrationsin Docker); depends onplayer.0020
mahjong_portal/settings.py
- Added
"vereinsmitglieder"toINSTALLED_APPS
Migration applied: vereinsmitglieder.0001_initial — OK
Improvement 10 ✓ DONE
Show an additional section under de/players/player for each player with the information if he has payed his club fee for the last three years. show him a column of years descending, with a checkmark or an x named Membership fees payed.
Implementation
player/views.py
- Added
from vereinsmitglieder.models import Mitgliedschaftsbeitrag - In
player_details(): computesfee_years = [current_year, current_year-1, current_year-2], queriesMitgliedschaftsbeitragfor those years, buildsmembership_fees = [{year, paid}, ...] - Passes
membership_feesin the render context
templates/player/details.html
- Added a new Bootstrap card "Mitgliedschaftsbeitrag" (green header, bill-list icon) between the Ratings section and the Latest Tournaments section
- Shows a two-column table (Jahr / Status) with ✓ (
text-success) or ✗ (text-danger) per year - Card is always rendered when
membership_feesis in context (list is always populated for the last 3 years)
Improvement 11 ✓ DONE
in quota event, only count tournaments in a given year to a player if he has payed his fees for this year. Under details, still show the tournament entries but color them light red and if mouseover display "Fee for xxxx not payed" (where xxxx is the year.)
Implementation
austria_ranking/calculator.py
- Added
from vereinsmitglieder.models import Mitgliedschaftsbeitrag - After building
player_lookup, initialisespaid_years_lookupwith an emptyset()for every portal player (key absent → player not in portal → no restriction; empty set → in portal, no fees paid → nothing counted) - Fills in paid years from
Mitgliedschaftsbeitrag; players not in the portal are simply absent from the lookup _year_ok()closure: returnsTrueonly if the result's year is in the player's paid-year set (or if the player is not tracked in the portal)- Result dicts store
"year": r.end_date.yearso_year_okcan check it; closure capturesportal_paid_yearsvia default-arg to avoid late-binding issues - AT points and foreign top-3 are computed only over fee-valid results
website/views.py
- Added
from vereinsmitglieder.models import Mitgliedschaftsbeitrag _annotate_unpaid_results(rankings, period): initialisespaid_years_by_emawith an emptyset()for every portal player (same sentinel logic as calculator); fills paid years fromMitgliedschaftsbeitrag; iterates allEmaTournamentResultfor the period; marks result PKs as unpaid whenend_date.year not in paid_years; players not in the portal are skipped (no restriction)- Called in both
rangliste()andrangliste_period()after the existing annotators
templates/website/rangliste.html
- Austrian tournament rows:
class="table-danger" title="Fee for {{ r.end_date.year }} not payed"whenr.pk in ranking.unpaid_result_pks, otherwiseclass="table-success" - Foreign tournament rows: same
table-dangeroverride takes priority overtable-successfor used-foreign-top-3 rows
Improvement 12 ✓ DONE
Add an dropwdpown action to player/player so i can bulk add entries to Mitgliedschaftsbeiträge for players. Name it "Set Yearly Club Fee status", upon selection ask for the year, and add an entry to Mitgliedschaftsbeiträge if it doesnt already exist.
Implementation
player/admin.py
- Added
set_yearly_club_fee(modeladmin, request, queryset)module-level action - First call (no
confirmedPOST key): renders intermediate template with selected player PKs as hidden fields and a year input (defaults to current year) - Second call (
confirmed=1): readsfee_yearfrom POST, callsMitgliedschaftsbeitrag.objects.get_or_create(player=player, year=year)for each selected player, reports how many were created vs already existed - Added
actions = [set_yearly_club_fee]toPlayerAdmin - Added imports:
date,messages,render,Mitgliedschaftsbeitrag
templates/admin/player/set_club_fee.html (new)
- Extends
admin/base_site.html; shows player count, year number input, hidden_selected_actionfields for the queryset PKs, and a submit button
Improvement 13 ✓ DONE
Add django-tinymce wysiwig to the project and make the following fields editable with it. in admin/tournament/tournament/ Tournament info tab
in admin/news/newsarticle/ Excerpt and body
Implementation
requirements/base.txt
- Added
django-tinymce==5.0.0
mahjong_portal/settings.py
- Added
"tinymce"toINSTALLED_APPS - Added
TINYMCE_DEFAULT_CONFIGwith toolbar: bold/italic/underline, lists, link, image, table, code; height 300px - Added
TINYMCE_FILEBROWSER = True(wires TinyMCE image picker to filebrowser — see Imp 14) - Fixed
STORAGESdict to include"default"key (required by Django 5 whenSTORAGESis explicitly defined)
mahjong_portal/urls.py
- Added
url(r"^tinymce/", include("tinymce.urls"))for TinyMCE JS/spellcheck endpoints
tournament/admin.py
- Added
from tinymce.widgets import TinyMCE TournamentForm.Meta.widgets: maps all 12 info fields toTinyMCE()—venue_address,schedule,lunch_options,contact_infoplus their_deand_entranslation variants
news/admin.py
- Replaced
format_html/image_previewwithTinyMCE()widgets onexcerptandbodyviaget_form()override - Removed
image_previewfromlist_display
Improvement 14 ✓ DONE
Add django-filebrowser to the project so i can add files and images and add them with django-tinymce. Also remove the image section in admin/news/newsarticle.
Implementation
requirements/base.txt
- Added
django-filebrowser-no-grappelli==4.0.2(filebrowser without Grappelli admin dependency) - Added
Pillow==12.2.0(required by filebrowser for image processing)
mahjong_portal/settings.py
- Added
"filebrowser"toINSTALLED_APPSbefore"django.contrib.admin"(required for template overrides) TINYMCE_FILEBROWSER = True— TinyMCE's "Insert Image" button opens the filebrowser dialog
mahjong_portal/urls.py
- Imported
filebrowser_siteand unpacked its 3-tuple (_fb_patterns, _fb_app, _fb_ns) - Added
url(r"^admin/filebrowser/", include((_fb_patterns, _fb_app), namespace=_fb_ns))— filebrowser accessible at/admin/filebrowser/
news/admin.py
- Added
exclude = ["image"]toNewsArticleAdmin— image URL field hidden from admin form (model field retained to avoid migration) - Removed
image_previewcolumn fromlist_display
File browser usage: navigate to /admin/filebrowser/ to upload and manage files; use the "Upload" button; files are stored in MEDIA_ROOT/uploads/ by default
Improvement 15 ✓ DONE
Change the GDPR section in tournament/tournament so i can select an uploaded file with django-filebrowser.
Implementation
tournament/models.py
- Added
from filebrowser.fields import FileBrowseField - Added
gdpr_file = FileBrowseField(…, directory="gdpr/", extensions=[".pdf", ".doc", ".docx"], null=True, blank=True)alongside the existinggdpr_documentFileField - Existing
gdpr_documentrenamed verbose_name to "… direct upload" to distinguish it in admin
tournament/migrations/0066_tournament_gdpr_file.py (manual)
AddFieldforgdpr_fileusingfilebrowser.fields.FileBrowseField; depends ontournament.0065; applied ✓
tournament/admin.py
- GDPR fieldset now shows both
gdpr_document(direct upload, backward-compat) andgdpr_file(filebrowser picker)
templates/tournament/announcement.html
- GDPR link: checks
tournament.gdpr_filefirst ({{ tournament.gdpr_file.url }}); falls back totournament.gdpr_document.url - Both Pantheon-registration and standard-registration form blocks updated
Workflow: upload a PDF via the filebrowser (/admin/filebrowser/), then select it in the tournament's GDPR fieldset → link appears above the consent checkbox on the registration page
Improvement 16
Add a mode in ausitran rankings that holds a text-field and is shown on top of de/rangliste that holds general information about quote events, make it editable with tinymce in the backend for de and en(with tabs)call it Informationen zum Qualifikationsmodus Add a text-field for the quota events that is displayed above the quota Rangliste but below the Qualifikationsmouds in de/rangliste/ for informations about thespecific Quota event. Call it Eventinformation where one can enter information about the quota events. Make it editable with tinymce in the backend.
Implementation
austria_ranking/models.py
QuotaEvent: addedevent_info = TextField(null=True, blank=True)— bilingual via modeltranslation (event_info_de,event_info_en)- New
QualificationModeInfosingleton model withinfo_text = TextField(bilingual) and aget_solo()classmethod that returns or createspk=1;has_add_permissionin admin hides "Add" once the singleton exists
austria_ranking/translation.py (new)
- Registers
QuotaEventwithfields = ["event_info"]andQualificationModeInfowithfields = ["info_text"]→ creates_de/_enDB columns;TabbedTranslationAdminshows language tabs
austria_ranking/admin.py
- Added
from modeltranslation.admin import TabbedTranslationAdminandfrom tinymce.widgets import TinyMCE QuotaEventAdminnow inheritsTabbedTranslationAdmin;get_form()injectsTinyMCE()widget forevent_infofields- New
QualificationModeInfoAdmin(TabbedTranslationAdmin)with TinyMCE forinfo_textfields
austria_ranking/migrations/0005_improvement16_qualification_mode_info.py (manual)
AddFieldforevent_info/event_info_de/event_info_enonQuotaEventCreateModelforQualificationModeInfowithinfo_text/info_text_de/info_text_en
website/views.py
- Imported
QualificationModeInfo; passedqualification_mode_info=QualificationModeInfo.get_solo()in bothrangliste()andrangliste_period()contexts
templates/website/rangliste.html
- Added a card block "Informationen zum Qualifikationsmodus" (shown when
qualification_mode_info.info_textis set) rendered via{{ ...|safe }} - Added a card block "Eventinformation" below it (shown when
period.event_infois set) for per-event info
Improvement 17
only show first name and abbreviated Surname if user is not logged in in the Rangliste in de/rangliste i.e. Michael Gürtl-Dusleag becomes Michael G. Also don't show the detailsbutton.
Implementation
templates/website/rangliste.html
- Header row:
<th></th>(Details column) wrapped in{% if user.is_authenticated %}...{% endif %} - Name cell: authenticated users see full name (with player profile link if available); anonymous users see
{{ parts.0 }} {{ parts|last|slice:":1" }}.— first name + first letter of last name using Django'ssplitvariable filter - Details button
<td>wrapped in{% if user.is_authenticated %}...{% endif %} - Details collapse
<tr>wrapped in{% if user.is_authenticated %}...{% endif %}— anonymous users cannot expand any row details
Improvement 18
The field Seats available: in Quota event should show a checkmark to the first x(where x is the number entered) players, that confirmed their attendance(stored in EventAttendanceIntent.status in austria_ranking) The column header should show x/y where x is the number of seats available minus confirmed players, and y is the Seats available for that event.
Implementation
website/views.py — _annotate_attendance
- Now computes
fixed_ema_idsfromperiod.fixed_seat_playersM2M (see Improvement 19) ranked_seat_count = seats_available - len(fixed_ema_ids)— ranked players compete only for non-fixed slotsall_seated = confirmed_fixed | ranked_seated— players with ✓- Attaches
period.seats_confirmedandperiod.seats_remainingto the period object (dynamic attributes)
templates/website/rangliste.html
- Period info line changed from
<strong>{{ period.seats_available }}</strong>to<strong>{{ period.seats_remaining }}/{{ period.seats_available }}</strong>— shows remaining/total
Improvement 19
I need to be able to mark a random player as qualified for a quota event because of outside factors other than ranking. Add a list field to the quota event that holds one or more player references that have a fixed seat, mark these players with a checkmark if they confirmed their attendance, otherwise add a crown symbol if they have not confirmed attendance(read from EventAttendanceIntent.status) If they earn a checkmark deduct them from x as well as in improvement 18.
Implementation
austria_ranking/models.py
QuotaEvent: addedfixed_seat_players = ManyToManyField(Player, blank=True, related_name="fixed_quota_events")with help text
austria_ranking/admin.py
QuotaEventAdmin: addedfilter_horizontal = ["fixed_seat_players"]— dual-select widget for assigning fixed-seat players
austria_ranking/migrations/0006_quotaevent_fixed_seat_players.py (manual)
AddFieldfor the M2Mfixed_seat_playersthrough-table
website/views.py — _annotate_attendance (also covers Improvement 18)
fixed_ema_ids: EMA IDs of all fixed-seat players for the periodconfirmed_fixed: subset that confirmed attendance → get ✓ and reduceseats_remaining- Non-confirmed fixed players →
ranking.has_crown = True(not inall_seated) ranked_seat_count = seats_available - len(fixed_ema_ids)(fixed players consume slots)ranking.has_crownset for fixed players who haven't confirmed attendance
templates/website/rangliste.html
- Name cell:
{% elif ranking.has_crown %}<span class="text-warning ...">👑</span>{% endif %}added after the ✓ check
Improvement 20 ✓ DONE
Implment a function to directly import tournament results from the linked pantheon tournamen(linked New pantheon id:) under admin/tournament/tournament/ with an action named "Load pantheon results". if a pantheon player is not linked to a player account in mahjong portal use load_player and just fill the player string with the name.
Implementation
server/tournament/admin.py
- Added
load_pantheon_results(modeladmin, request, queryset)admin action function - Checks that exactly one tournament is selected
- Validates tournament has
new_pantheon_idset - Calls
get_rating_table(tournament.new_pantheon_id)to fetch results from Pantheon - Iterates through Pantheon rating table:
- Attempts to match player by
Player.objects.get(pantheon_id=pantheon_id) - For unlinked players: creates TournamentResult with
player=Noneandplayer_string=title - For linked players: creates TournamentResult with
playerFK and emptyplayer_string
- Attempts to match player by
- Uses
TournamentResult.objects.update_or_create()to handle both new and existing results - Provides detailed success message with counts: created, updated, and unlinked players
- Added to
TournamentAdmin.actions = [load_pantheon_results]
Key Features:
- Atomic transaction ensures all-or-nothing import
- Graceful handling of unlinked players (load_player=false pattern)
- Admin messages for all error conditions (no pantheon_id, API failure, empty results)
- Statistics tracking for transparency (created vs updated, unlinked player count)
- Uses existing
utils.new_pantheon.get_rating_table()infrastructure
Data Flow:
- Admin selects tournament(s) in admin list view
- Chooses "Load Pantheon results" from Actions dropdown
- Function validates single selection and pantheon_id presence
- Fetches rating table from Pantheon API via
get_rating_table() - Creates/updates TournamentResult for each player in rating table
- Shows success message with import statistics
Improvement 20 ✓ DONE
- Make Player profile page i.e. /en/players/gurtl-dusleag-michael/ inaccessible if not logged in.
- show the section Mitgliedschaftsbeitrag only if user views his own page or if user is ema players manager or superuser.
Implementation
player/views.py
- Added
from django.contrib.auth.decorators import login_required - Decorated
player_details()with@login_required— unauthenticated users are redirected to login page - Added
show_membership_feesflag logic with authentication check:- Only evaluates permissions if
request.user.is_authenticated - Shows fees when
request.user.attached_player_idis not None AND equalsplayer.id(user viewing own profile) - OR when
request.user.is_ema_players_manager(EMA players manager role) - OR when
request.user.is_superuser(admin role) - All other cases:
show_membership_fees = False
- Only evaluates permissions if
- Passed
show_membership_feesin render context
templates/player/details.html
- Changed membership fees section condition from
{% if membership_fees %}to{% if membership_fees and show_membership_fees %} - Section now hidden unless user is: the player themselves, EMA players manager, or superuser
Access Control:
- Player profiles require authentication (Django redirects to login with
?next=parameter) - Membership fee status visible only to authorized users (player themselves, EMA players manager, or superuser)
- Other profile sections (ratings, tournaments, club ratings) visible to all authenticated users
Improvement 21 ✓ DONE
I want to behave tournament ranking views like en/tournaments/riichi/rmp-riichi-open/ Similar to projects/mahjong-portal/server/templates/website/rangliste.html as to following points
- dont show full names but abbreviate Surname to First Letter and ., i.e. Franz Huber -> Franz H.
- don't link to player profiles
Implementation
mahjong_portal/templatetags/player_helper.py
- Added custom
splittemplate filter: splits a string by delimiter (default space) and returns a list - Required because Django doesn't have a built-in split filter
- Used to parse player_string format "LastName FirstName" into parts for abbreviation
templates/tournament/_tournament_results.html
- Added
{% load player_helper %}to access the custom split filter - Implemented conditional display based on authentication:
- Authenticated users: Full name with link using
{% include 'common/_player_name.html' %} - Anonymous users: Abbreviated name without link:
{{ player.first_name }} {{ player.last_name|slice:":1" }}.
- Authenticated users: Full name with link using
- For
player_string(non-linked players, anonymous users): splits on space using customsplitfilter, showsFirstName L.format - Substitution/replacement players still show full name (as before)
Name Abbreviation Logic (anonymous users only):
- Linked players:
{{ player.first_name }} {{ player.last_name|slice:":1" }}. - Player strings:
{{ parts.0 }} {{ parts|last|slice:":1" }}.after splitting on space - Handles single-word names gracefully (shows full name if no space to split on)
Privacy & Consistency:
- Tournament results now show abbreviated names for anonymous users, matching rangliste behavior
- Authenticated users see full names with clickable links to player profiles
- Consistent with Improvement 17's privacy approach for unauthenticated rangliste viewers
- Replacement players exempt from abbreviation (show full name as substitution notice)
Improvement 22
A player wo runs an austrian tournament but does not play in it, gets the average of his Austrian tournament results for that quota event counted towards his score. Implement it in this way.
- Add a field to the tournament model, called "non playing organizer" that lets me select a "player".
- While running the ranking calculation for a Quota event, check if a player is set as an organizer in a tournament(only if he is non playing). If so, calculate the avg base points of all tournaments he played for that quota between start and end date, and that result as EMA Tournament Result with position 999.
- Add that result normally to his austrian ranking and display on the page.
Implementation
Files changed:
server/tournament/models.py— addednon_playing_organizer = ForeignKey(Player, null=True, blank=True, on_delete=SET_NULL, related_name="organized_tournaments")to theTournamentmodel.server/tournament/admin.py— added"non_playing_organizer"toTournamentAdmin.fieldsetsso the field is visible and editable in Django admin.server/tournament/migrations/0068_add_non_playing_organizer.py— migration for the new field.server/austria_ranking/calculator.py:- Added constant
ORGANIZER_BONUS_TOURNAMENT_NAME = "Veranstalter-Ø". - Added helper function
_inject_organizer_bonuses(quota_period, player_data, player_lookup)that:- Queries all
Tournamentobjects whoseend_datefalls in the quota period and that havenon_playing_organizerset. - For each organizer player (identified by
Player.ema_id), skips if they have no AT results inplayer_data(no average can be computed). - Calculates
avg_points = round(sum(at_points) / count). - Appends a synthetic entry to
player_data[ema_id]["at_results"]so the calculator includes it in the AT score. - Creates (or updates) a persistent
EmaTournamentResultrow withposition=999,tournament_name="Veranstalter-Ø",is_austrian_tournament=True, using the quota period'send_dateas the result date. This is what the rangliste detail view reads.
- Queries all
- In
rank_players_for_period(): before scoring, deletes any stale"Veranstalter-Ø"rows for the period, then calls_inject_organizer_bonuses.
- Added constant
server/templates/website/rangliste.html— in the AT-tournaments detail table, rows withr.position == 999are styledtable-infoand their tournament name cell shows the name in italics with aVeranstalterbadge instead of an EMA link.
Behaviour:
- Admins set a
non_playing_organizeron aTournamentvia the Django admin. - Each time the ranking is recalculated (
rank_players_for_period), the organizer bonus is recomputed from scratch (stale rows are deleted first). - An organizer only receives the bonus if they have at least one real AT result in the period; otherwise they would not normally appear in the ranking at all.
- If a player organizes multiple tournaments in the same period, the bonus is computed once (average of all their AT results) — the first organized tournament chronologically triggers the bonus.
- The bonus row is displayed in the "Österreich-Turniere" detail section with a teal (
table-info) background and a greyVeranstalterbadge.
Improvement 23 ✓ DONE
EMA player pages can contain results from both Riichi (TR_RCR_XXX.html) and MCR (TR_XXX.html) tournaments. Only Riichi tournaments count towards the Austrian ranking. The scraper was previously stopping at the first HallFame table, which could be the MCR table for players with both rulesets — causing their Riichi results to be missed entirely.
Requirements
- Only import Riichi tournaments (TR_RCR_ URLs), ignore MCR entirely.
- Ensure all Riichi results are imported for players who also have MCR results.
Implementation
austria_ranking/scraper.py
- In
scrape_player_results(): collects all<table>elements on the player page that containHallFame_cells. If there is only one such table it is used directly (Riichi-only players). If there are multiple (player has both MCR and Riichi results), the one whose nearest preceding<h3>contains "Riichi" is selected viatbl.find_previous("h3"). This avoids the false positive from the standalone nav heading<h3>Riichi</h3>that appears before "Riichi Results" in the page.
austria_ranking/models.py
- No
game_typefield added — all storedEmaTournamentResultrows are Riichi by definition.
austria_ranking/migrations/0008_ematournamentresult_game_type.py
- Uses
RunSQLtoDROP COLUMN IF EXISTS game_type(the column was briefly added to the DB during development and needed cleaning up).
austria_ranking/admin.py / austria_ranking/calculator.py
- No changes needed — no game_type filter or display required.
austria_ranking/calculator.py
- In
rank_players_for_period(): changed the base queryset toEmaTournamentResult.objects.filter(..., game_type=0)so only Riichi results feed the ranking - In
_inject_organizer_bonuses(): organizer-bonus synthetic rows are created withgame_type=0(Riichi) so they are included in the filtered queryset
Improvement 24 ✓ DONE
Only show full names and additional details in the following pages
- de/rangliste/
- de/tournaments/riichi/tournament-name
- /de/tournaments/riichi/tournament-name/announcement/ when user is logged in AND has a connected Player profile. Right now being logged in is enough.
Implementation
templates/website/rangliste.html
- All occurrences of
{% if user.is_authenticated %}that gate full-name display, player profile links, the Details column header, the Details button, and the Details collapse row changed to{% if user.is_authenticated and user.attached_player %} - The crown/seat ✓ indicator condition similarly updated to
{% if user.is_authenticated and user.attached_player and ranking.has_seat %} - Users who are logged in but have no linked player profile now see abbreviated names (same as anonymous users)
templates/tournament/_tournament_results.html
{% if user.is_authenticated %}→{% if user.is_authenticated and user.attached_player %}for the full-name-with-link block in tournament result rows
templates/tournament/announcement.html
{% if user.is_authenticated %}→{% if user.is_authenticated and user.attached_player %}for the participants list name display- Comment updated from "anonymous users" to "users without a linked player profile"
Improvement 25 ✓ DONE
If a player is logged in but has no connected player profile add an option under https://portal.riichimahjong.at/de/account/settings/ to create an "Attach player request" (from account app) that can then be approved by an admin in django admin.
Implementation
account/views.py
- Added
from django.db.models import Qimport - Added
player_search_results,player_search_query,player_search_performed, andpending_attach_requestvariables - On page load: queries any unprocessed
AttachingPlayerRequestfor the current user (if no attached player) - Two new POST action branches:
action=search_player: queriesPlayerby first/last name usingQ(first_name__icontains=…) | Q(last_name__icontains=…), returns up to 20 matches; setsplayer_search_performed=Trueaction=create_attach_request: looks up player by PK, createsAttachingPlayerRequestif no identical pending request exists, redirects with success message; duplicate submissions are silently ignored
- Tenhou update path preserved as
elif(no behavioural change) - New context variables passed to template:
pending_attach_request,player_search_results,player_search_query,player_search_performed
templates/account/settings.html
- "Player Profile" card now has three states when
not user.attached_player:- Pending request: shows info alert with pending player name
- Search results (
player_search_performedand results exist): shows radio-button list of matching players + contacts textarea + submit - No match (
player_search_performedand empty results): shows "no players found" warning - Search form (step 1, shown when no results yet): first/last name text input + Search button
account/admin.py
- Added
approve_attach_requestadmin action toAttachingPlayerRequestAdmin:- Iterates unprocessed requests in the queryset
- Sets
user.attached_player = request.playerandrequest.is_processed = True - Reports count of approved requests via
message_user
actions = ["approve_attach_request"]added to the admin class
Improvement 26 ✓ DONE
Change the non-playing organizer Field in tournament model to list of players and make it editable in admin/tournament/tournament/6/change/. The formula for calculating the organizer bonus per tournament in a given quota period for x players is the following for player y:
((all austrian ranking points in the quota period(betwen start date and end date) for player y)/(number of played austrian tournaments played in given quota period for player x))/x
Adapt the ranking calculation to fit the above.
Implementation
tournament/models.py
non_playing_organizer(ForeignKey) removed; replaced withnon_playing_organizers(ManyToManyField(Player, blank=True, related_name="organized_tournaments"))
tournament/migrations/0069_tournament_non_playing_organizers_m2m.py (manual)
RemoveFieldfor oldnon_playing_organizerFKAddFieldfor newnon_playing_organizersM2M
tournament/admin.py
filter_horizontalextended to include"non_playing_organizers"— renders the dual-select widget in admin- Fieldset entry renamed from
"non_playing_organizer"to"non_playing_organizers"
austria_ranking/calculator.py — _inject_organizer_bonuses()
- Queryset changed from
non_playing_organizer__isnull=False+select_related→non_playing_organizers__isnull=False+.prefetch_related("non_playing_organizers").distinct() - Tracks
(tournament_pk, ema_id)pairs (not just ema_id) so a player can receive a bonus for multiple tournaments they organized x = len(organizers)— number of non-playing organizers on the tournament- New formula:
bonus = round((sum_AT_points / count_AT_tournaments) / x)sum_AT_points= total AT points player y earned in the quota periodcount_AT_tournaments= number of AT results player y already has inplayer_data- dividing by
xsplits the bonus among all co-organizers
player_count=xstored in the persistedEmaTournamentResultrow (was1before)