Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/eventyay/base/forms/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from eventyay.base.models import User
from eventyay.control.forms import SingleLanguageWidget
from eventyay.person.utils import is_wikimedia_user


class UserSettingsForm(forms.ModelForm):
Expand Down Expand Up @@ -62,7 +63,8 @@ class Meta:
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['email'].required = True
# Make email optional for Wikimedia users
self.fields['email'].required = not is_wikimedia_user(self.user)
self.fields['wikimedia_username'].widget.attrs['readonly'] = True
if self.user.auth_backend != 'native':
del self.fields['old_pw']
Expand Down
21 changes: 21 additions & 0 deletions app/eventyay/base/migrations/0006_user_is_wikimedia_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('base', '0005_alter_logentry_data'),
]

operations = [
migrations.AddField(
model_name='user',
name='is_wikimedia_user',
field=models.BooleanField(default=False, verbose_name='Is Wikimedia user'),
),
migrations.AlterField(
model_name='user',
name='wikimedia_username',
field=models.CharField(max_length=255, blank=True, null=True, unique=True, verbose_name='Wikimedia username'),
),
]
26 changes: 22 additions & 4 deletions app/eventyay/base/models/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from eventyay.common.text.path import path_with_hash
from eventyay.common.urls import EventUrls
from eventyay.helpers.urls import build_absolute_uri
from eventyay.person.utils import is_placeholder_email
from eventyay.talk_rules.person import is_administrator

from ...helpers.u2f import pub_key_from_der, websafe_decode
Expand Down Expand Up @@ -163,7 +164,8 @@ class UserType(models.TextChoices):
unique=True, db_index=True, null=True, blank=True, verbose_name=_('E-mail'), max_length=190
)
fullname = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('Full name'))
wikimedia_username = models.CharField(max_length=255, blank=True, null=True, verbose_name=('Wikimedia username'))
wikimedia_username = models.CharField(max_length=255, blank=True, null=True, unique=True, verbose_name=_('Wikimedia username'))
is_wikimedia_user = models.BooleanField(default=False, verbose_name=_('Is Wikimedia user'))
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
is_staff = models.BooleanField(default=False, verbose_name=_('Is site admin'))
date_joined = models.DateTimeField(auto_now_add=True, verbose_name=_('Date joined'))
Expand Down Expand Up @@ -359,6 +361,9 @@ def get_full_name(self) -> str:
def send_security_notice(self, messages, email=None):
from eventyay.base.services.mail import SendMailException, mail

# Skip email notifications for placeholder Wikimedia emails
if is_placeholder_email(self.email):
return

try:
with language(self.locale):
Expand All @@ -379,6 +384,10 @@ def send_security_notice(self, messages, email=None):
def send_password_reset(self, request: HttpRequest):
from eventyay.base.services.mail import mail

# Skip email notifications for placeholder Wikimedia emails
if is_placeholder_email(self.email):
return

subject = _('Password recovery')
security_token = default_token_generator.make_token(self)
base_action_url = reverse('eventyay_common:auth.forgot.recover')
Expand Down Expand Up @@ -692,6 +701,12 @@ def reset_password(self, event, user=None, mail_text=None, orga=False):
self.pw_reset_time = now()
self.save()

self.log_action(action='eventyay.user.password.reset', user=user)

# Skip email notifications for placeholder Wikimedia emails
if is_placeholder_email(self.email):
return

context = {
'name': self.fullname or '',
'url': self.get_password_reset_url(event=event, orga=orga),
Expand All @@ -718,7 +733,6 @@ def reset_password(self, event, user=None, mail_text=None, orga=False):
locale=self.locale,
to=self.email,
).send()
self.log_action(action='eventyay.user.password.reset', user=user)

reset_password.alters_data = True

Expand All @@ -734,6 +748,12 @@ def change_password(self, new_password):
self.pw_reset_time = None
self.save()

self.log_action(action='eventyay.user.password.changed', user=self)

# Skip email notifications for placeholder Wikimedia emails
if is_placeholder_email(self.email):
return

context = {
'name': self.name or '',
}
Expand All @@ -756,8 +776,6 @@ def change_password(self, new_password):
to=self.email,
).send()

self.log_action(action='eventyay.user.password.changed', user=self)

change_password.alters_data = True

# @transaction.atomic
Expand Down
1 change: 1 addition & 0 deletions app/eventyay/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
# url(r'^api/v1/', include(('eventyay.api.urls', 'eventyayapi'), namespace='api-v1')),
# url(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
url(r'^accounts/', include('allauth.urls')),
url(r'^', include('eventyay.plugins.socialauth.urls')),
]

control_patterns = [
Expand Down
69 changes: 69 additions & 0 deletions app/eventyay/person/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
def get_or_create_email_for_wikimedia_user(username, email=None, user_id=None):
"""
Generate an email for Wikimedia users.

Sanitizes username to ensure valid email format and uniqueness.
Falls back to user ID if username is empty or invalid.

Args:
username (str): Wikimedia username
email (str): Email from Wikimedia profile (can be None)
user_id: Wikimedia user ID for uniqueness and fallback

Returns:
str: Email address
"""
if email and email.strip():
return email

# Sanitize username: lowercase, strip, replace whitespace with dots, remove invalid chars
if username:
sanitized = username.lower().strip()
sanitized = sanitized.replace(' ', '.').replace('_', '.')
sanitized = ''.join(c for c in sanitized if c.isalnum() or c in '.-')
while '..' in sanitized:
sanitized = sanitized.replace('..', '.')
sanitized = sanitized.strip('.')
else:
sanitized = None

# If sanitized username is empty or invalid, use user ID as fallback
if sanitized:
if user_id:
return f"{sanitized}.{user_id}@wikimedia.local"
return f"{sanitized}@wikimedia.local"
else:
# Fallback: use Wikimedia user ID only
if user_id:
return f"wm.{user_id}@wikimedia.local"
else:
return "[email protected]"


def is_placeholder_email(email):
"""
Check if an email is a placeholder (non-routable) Wikimedia address.
These emails should never have messages sent to them.

Args:
email (str): Email address to check

Returns:
bool: True if the email is a placeholder Wikimedia email
"""
if not email:
return False
return email.endswith('@wikimedia.local')


def is_wikimedia_user(user):
"""
Check if user is authenticated via Wikimedia OAuth

Args:
user: Django User instance

Returns:
bool: True if user is a Wikimedia OAuth user
"""
return user.is_authenticated and getattr(user, 'is_wikimedia_user', False)
68 changes: 53 additions & 15 deletions app/eventyay/plugins/socialauth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from eventyay.control.permissions import AdministratorPermissionRequiredMixin
from eventyay.eventyay_common.views.auth import process_login_and_set_cookie
from eventyay.helpers.urls import build_absolute_uri
from eventyay.person.utils import get_or_create_email_for_wikimedia_user

from .schemas.login_providers import LoginProviders
from .schemas.oauth2_params import OAuth2Params
Expand Down Expand Up @@ -95,28 +96,65 @@ def get_or_create_user(request: HttpRequest) -> User:
social_account = request.user.socialaccount_set.filter(
provider='mediawiki'
).last() # Fetch only the latest signed in Wikimedia account
email = request.user.email
wikimedia_username = ''

if social_account:
extra_data = social_account.extra_data
wikimedia_username = extra_data.get('username', extra_data.get('realname', ''))

user, created = User.objects.get_or_create(
email=request.user.email,
defaults={
'locale': getattr(request, 'LANGUAGE_CODE', settings.LANGUAGE_CODE),
'timezone': getattr(request, 'timezone', settings.TIME_ZONE),
'auth_backend': 'native',
'password': '',
'wikimedia_username': wikimedia_username,
},

# Guard: ensure we have a valid username
if not wikimedia_username or not wikimedia_username.strip():
# Use social account ID as fallback
wikimedia_username = f"wm_{social_account.uid}"

# Generate placeholder email for Wikimedia users without email
final_email = get_or_create_email_for_wikimedia_user(
wikimedia_username,
email,
user_id=social_account.uid if social_account else None
)

# Update wikimedia_username if the user exists but has no wikimedia_username value set
# (basically our existing users), or if the user has updated his username in his wikimedia account
if not created and (not user.wikimedia_username or user.wikimedia_username != wikimedia_username):
user.wikimedia_username = wikimedia_username
user.save()
# For Wikimedia users, look up by wikimedia_username instead of email
# to avoid collisions between placeholder emails
if social_account:
user, created = User.objects.get_or_create(
wikimedia_username=wikimedia_username,
defaults={
'email': final_email,
'locale': getattr(request, 'LANGUAGE_CODE', settings.LANGUAGE_CODE),
'timezone': getattr(request, 'timezone', settings.TIME_ZONE),
'auth_backend': 'mediawiki',
'password': '',
'is_wikimedia_user': True,
},
)
else:
# Non-Wikimedia users: use email as before
user, created = User.objects.get_or_create(
email=final_email,
defaults={
'locale': getattr(request, 'LANGUAGE_CODE', settings.LANGUAGE_CODE),
'timezone': getattr(request, 'timezone', settings.TIME_ZONE),
'auth_backend': 'native',
'password': '',
},
)
Comment on lines +124 to +134
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using get_or_create() with email=final_email where final_email can be None (line 109) will cause a database integrity error or unexpected behavior. The User model has email as unique, and multiple users with email=None will violate the uniqueness constraint on most databases (except PostgreSQL which treats NULL values as distinct). This could result in an IntegrityError for non-Wikimedia users without email addresses.

Copilot uses AI. Check for mistakes.

# Update wikimedia_username and is_wikimedia_user if the user exists
if not created:
update_fields = []
if not user.wikimedia_username or user.wikimedia_username != wikimedia_username:
user.wikimedia_username = wikimedia_username
update_fields.append('wikimedia_username')

# Update is_wikimedia_user if user exists and is logging in via Wikimedia
if social_account and not user.is_wikimedia_user:
user.is_wikimedia_user = True
update_fields.append('is_wikimedia_user')

if update_fields:
user.save(update_fields=update_fields)

return user

Expand Down
19 changes: 19 additions & 0 deletions app/eventyay/presale/forms/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from eventyay.base.i18n import get_babel_locale, language
from eventyay.base.validators import EmailBanlistValidator
from eventyay.person.utils import is_wikimedia_user
from eventyay.presale.signals import contact_form_fields


Expand All @@ -34,11 +35,21 @@ def __init__(self, *args, **kwargs):
self.all_optional = kwargs.pop('all_optional', False)
super().__init__(*args, **kwargs)

# Make email optional for Wikimedia users
if self.request and self.request.user and is_wikimedia_user(self.request.user):
self.fields['email'].required = False
self.fields['email'].help_text = _('E-mail (optional for Wikimedia users)')
else:
self.fields['email'].required = True

if self.event.settings.order_email_asked_twice:
self.fields['email_repeat'] = forms.EmailField(
label=_('E-mail address (repeated)'),
help_text=_('Please enter the same email address again to make sure you typed it correctly.'),
)
# Make email_repeat optional for Wikimedia users too
if self.request and self.request.user and is_wikimedia_user(self.request.user):
self.fields['email_repeat'].required = False

if self.event.settings.order_phone_asked:
with language(get_babel_locale()):
Expand Down Expand Up @@ -82,6 +93,13 @@ def __init__(self, *args, **kwargs):
v.widget.is_required = False

def clean(self):
cleaned_data = super().clean()

# For Wikimedia users, skip email validation
if self.request and self.request.user and is_wikimedia_user(self.request.user):
return cleaned_data

# Validate email_repeat matches email if both are provided
if (
self.event.settings.order_email_asked_twice
and self.cleaned_data.get('email')
Expand All @@ -90,6 +108,7 @@ def clean(self):
if self.cleaned_data.get('email').lower() != self.cleaned_data.get('email_repeat').lower():
raise ValidationError(_('Please enter the same email address twice.'))

return cleaned_data

class InvoiceAddressForm(BaseInvoiceAddressForm):
required_css_class = 'required'
Expand Down
Empty file added tests/__init__.py
Empty file.
Empty file added tests/person/__init__.py
Empty file.
Loading