diff --git a/app/eventyay/base/forms/user.py b/app/eventyay/base/forms/user.py index 5d1e79c761..3344072c4a 100644 --- a/app/eventyay/base/forms/user.py +++ b/app/eventyay/base/forms/user.py @@ -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): @@ -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'] diff --git a/app/eventyay/base/migrations/0006_user_is_wikimedia_user.py b/app/eventyay/base/migrations/0006_user_is_wikimedia_user.py new file mode 100644 index 0000000000..704b48ccb1 --- /dev/null +++ b/app/eventyay/base/migrations/0006_user_is_wikimedia_user.py @@ -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'), + ), + ] diff --git a/app/eventyay/base/models/auth.py b/app/eventyay/base/models/auth.py index ca9b0d5071..fc1bf24d8a 100644 --- a/app/eventyay/base/models/auth.py +++ b/app/eventyay/base/models/auth.py @@ -178,7 +178,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')) @@ -374,6 +375,8 @@ def get_full_name(self) -> str: def send_security_notice(self, messages, email=None): from eventyay.base.services.mail import SendMailException, mail + if not self.email: + return try: with language(self.locale): @@ -394,6 +397,9 @@ def send_security_notice(self, messages, email=None): def send_password_reset(self, request: HttpRequest): from eventyay.base.services.mail import mail + if not self.email: + return + subject = _('Password recovery') security_token = default_token_generator.make_token(self) base_action_url = reverse('eventyay_common:auth.forgot.recover') @@ -707,6 +713,11 @@ 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) + + if not self.email: + return + context = { 'name': self.fullname or '', 'url': self.get_password_reset_url(event=event, orga=orga), @@ -733,7 +744,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 @@ -750,6 +760,11 @@ def change_password(self, new_password): self.pw_reset_time = None self.save() + self.log_action(action='eventyay.user.password.changed', user=self) + + if not self.email: + return + context = { 'name': self.name or '', } @@ -772,8 +787,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 diff --git a/app/eventyay/config/urls.py b/app/eventyay/config/urls.py index 3a9425e3e0..6e3ebfdcac 100644 --- a/app/eventyay/config/urls.py +++ b/app/eventyay/config/urls.py @@ -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 = [ diff --git a/app/eventyay/person/utils.py b/app/eventyay/person/utils.py new file mode 100644 index 0000000000..7ab21eb5b4 --- /dev/null +++ b/app/eventyay/person/utils.py @@ -0,0 +1,11 @@ +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) diff --git a/app/eventyay/plugins/socialauth/views.py b/app/eventyay/plugins/socialauth/views.py index 003c875fd6..f56ccb7e86 100644 --- a/app/eventyay/plugins/socialauth/views.py +++ b/app/eventyay/plugins/socialauth/views.py @@ -18,7 +18,6 @@ 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 .schemas.login_providers import LoginProviders from .schemas.oauth2_params import OAuth2Params @@ -95,28 +94,59 @@ 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', '')) + + # 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}" + + final_email = email if (email and str(email).strip()) else None + + # For Wikimedia users, look up by wikimedia_username instead of email + 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': '', + }, + ) - 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, - }, - ) - - # 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() + # 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 diff --git a/app/eventyay/presale/forms/checkout.py b/app/eventyay/presale/forms/checkout.py index b93d5ca550..cc49edbb18 100644 --- a/app/eventyay/presale/forms/checkout.py +++ b/app/eventyay/presale/forms/checkout.py @@ -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 @@ -34,11 +35,18 @@ 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 address (optional for Wikimedia users)') + 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.'), ) + 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()): @@ -82,6 +90,11 @@ 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') @@ -90,6 +103,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' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/person/__init__.py b/tests/person/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/person/test_wikimedia_utils.py b/tests/person/test_wikimedia_utils.py new file mode 100644 index 0000000000..c5ab833eca --- /dev/null +++ b/tests/person/test_wikimedia_utils.py @@ -0,0 +1,47 @@ +""" +Tests for Wikimedia user utilities. +""" +from django.test import TestCase +from django.contrib.auth import get_user_model + +from eventyay.person.utils import is_wikimedia_user + +User = get_user_model() + + +class WikimediaUserUtilsTests(TestCase): + """Test utilities for identifying Wikimedia OAuth users.""" + + def test_is_wikimedia_user_true(self): + """Wikimedia users correctly identified.""" + user = User.objects.create_user( + email='test@example.com', + wikimedia_username='testuser', + is_wikimedia_user=True + ) + self.assertTrue(is_wikimedia_user(user)) + + def test_is_wikimedia_user_false(self): + """Regular users not identified as Wikimedia users.""" + user = User.objects.create_user(email='test@example.com') + self.assertFalse(is_wikimedia_user(user)) + + def test_is_wikimedia_user_no_email(self): + """Wikimedia users can have no email.""" + user = User.objects.create_user( + email=None, + wikimedia_username='testuser', + is_wikimedia_user=True + ) + self.assertTrue(is_wikimedia_user(user)) + self.assertIsNone(user.email) + + def test_is_wikimedia_user_unauthenticated(self): + """Unauthenticated users are not Wikimedia users.""" + user = User.objects.create_user( + email='test@example.com', + wikimedia_username='testuser', + is_wikimedia_user=True + ) + user.is_authenticated = False + self.assertFalse(is_wikimedia_user(user))