import json from django import forms from django.conf import settings from django.http import HttpResponseRedirect from django.shortcuts import render from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.contrib.auth.forms import PasswordResetForm from troggle.core.models.troggle import DataIssue, Person from troggle.parsers.users import register_user, get_encryptor, ENCRYPTED_DIR, USERS_FILE from troggle.parsers.people import troggle_slugify from troggle.core.utils import ( add_commit, is_identified_user, is_admin_user, ) from troggle.core.views.auth import expologout """ This is the new 2025 individual user login registration, instead of everyone signing in as "expo". This may be useful for the kanban expo organisation tool. It also will eventually (?) replace the cookie system for assigning responsibility to git commits. This registration system has logic spread between these functions and the Django templates. It seriously needs refactoring as the logic is opaque. Before that, it needs a full set of tests to check all the combinations of users/logged-on/pre-registered/admin etc. etc. """ todo = """ - Make all this work with New people who have never been on expo before - add a queue/email to be approved by an admin - Stop anyone re-registering an email for an id which already has an email (unless by an admin) - login automatically, and redirect to control panel ? """ class ExpoPasswordResetForm(PasswordResetForm): """Because we are using the Django-standard django.contrib.auth mechanisms, the way Django wants us to modify them is to subclass their stuff and insert our extras in the subclasses. This is completely unlike how the rest of troggle works because we avoid Class-based views. We avoid them because they are very hard to debug for newcomer programmers who don't know Django: the logic is spread out up a tree of preceding ancestor classes. This is where we would override the template so make the form look visually like the rest of troggle """ def clean_email(self): email = self.cleaned_data.get('email') # Add custom validation logic etc. print(f" * ExpoPasswordResetForm PASSWORD reset email posted '{email=}'") # method_list = [attribute for attribute in dir(PasswordResetForm) if callable(getattr(PasswordResetForm, attribute)) and attribute.startswith('__') is False] # print(method_list) return email def reset_done(request): """This page is called when a password reset has successively occurred. Unfortunately by this point, we do not know the name of the user who initiated the password reset, so when we do the git commit of the encrypted users file we do not have a name to put to the responsible person. To do that, we would have to intercept at the previous step, the url: "reset///", views.PasswordResetConfirmView.as_view(), and this class-based view is a lot more complicated to replace or sub-class. Currently we are doing the git commit anonymously.. though I guess we could attempt to read the cookie... if it is set. """ current_user = request.user save_users(request, current_user) if current_user.is_anonymous: # What we expect, for a completely new user return HttpResponseRedirect("/accounts/login/?next=/handbook/troggle/training/trogbegin.html") else: # This would be for someone already looged in "expo" for example return HttpResponseRedirect("/handbook/troggle/training/trogbegin.html") def newregister(request, username=None): """To register a COMPLETELY new user on the troggle system, WITHOUT any previous expo attendance. Currently allows random anyone to add suff to our system. Yuk. This is DISABLED pending implementing a queue/confirm mechanism except for admin users. """ current_user = request.user # if not logged in, this is 'AnonymousUser' warning = "" if request.method == "POST": form = newregister_form(request.POST) if form.is_valid(): fullname = form.cleaned_data["fullname"] email = form.cleaned_data["email"] nameslug = troggle_slugify(fullname) print(f"NEW user slug {nameslug}") if User.objects.filter(username=nameslug).count() != 0: # Disallow a name which already exists, use the other form. return HttpResponseRedirect(f"/accounts/register/{nameslug}") # create User in the system and refresh stored encrypted user list and git commit it: if is_admin_user(request.user): updated_user = register_user(nameslug, email, password=None, pwhash=None, fullname=fullname) save_users(request, updated_user, email) return HttpResponseRedirect("/accounts/password_reset/") else: return render(request, "login/register.html", {"form": form, "warning": "Only ADMINs can do this", "newuser": True}) else: # GET form = newregister_form(initial={"visible-passwords": "True"}) return render(request, "login/register.html", {"form": form, "warning": warning, "newuser": True}) def re_register_email(request): """For a logged-on user: - change the email address - trigger reset password ( by email token) and we ignore any username specified in the URL of the request. """ logged_in = (identified_login := is_identified_user(request.user)) # logged_in used on form if not logged_in: return HttpResponseRedirect("/accounts/login/") u = request.user initial_values = {} initial_values.update({"username": u.username}) initial_values.update({"email": u.email}) if request.method == "POST": form = register_email_form(request.POST) # only username and password if form.is_valid(): print("POST VALID") email = form.cleaned_data["email"] u.email = email u.save() save_users(request, u, email) return render(request, "login/register_email.html", {"form": form, "confirmed": True}) else: print("POST INVALID") return render(request, "login/register_email.html", {"form": form}) else: # GET form = register_email_form(initial=initial_values) return render(request, "login/register_email.html", {"form": form}) def reshow_disabled(request, url_username, initial_values, warning, admin_notice): """Shows the form, but completely disabled, with messages showing what the user did wrong or inappropriately. Could all be replaced by form validation methods ? """ print(warning) print(admin_notice) form = register_form(initial=initial_values) form.fields["username"].widget.attrs["readonly"]="readonly" form.fields["email"].widget.attrs["readonly"]="readonly" form.fields["password1"].widget.attrs["readonly"]="readonly" form.fields["password2"].widget.attrs["readonly"]="readonly" return render(request, "login/register.html", {"form": form, "admin_notice": admin_notice, "warning": warning}) # not used, loops: return HttpResponse warning, admin_notice): Redirect(f"/accounts/login/?next=/accounts/register/{url_username}") def register(request, url_username=None): """To register an old expoer as a new user on the troggle system, for someone who has previously attended expo. Authority this gives is the same as the "expo" user (with cavey:beery password) but specific to an individual. """ warning = "" admin_notice = "" initial_values={"visible-passwords": "True"} print(f"{url_username=} {request.user.username=}") # Since this form is for old expoers, we can expect that they know how to login as 'expo'. So require this at least. if request.user.is_anonymous: warning = "ANONYMOUS users not allowed to Register old expoers. Login, e.g. as 'expo', and try again." return reshow_disabled(request, url_username, initial_values, warning, admin_notice) if url_username: # if provided in URL, but if POST then this could alternatively be in the POST data field ["username"] # print(url_username, "Person count",Person.objects.filter(slug=url_username).count()) # print(url_username, "User count",User.objects.filter(username=url_username).count()) if (Person.objects.filter(slug=url_username).count() != 1): # not an old expoer, so redirect to the other form for completely new cavers, # but suppose it was a typo? Better to check this in response to a POST surely? # To do: do this as a form-valiation action. return HttpResponseRedirect("/accounts/newregister/") already_registered = (User.objects.filter(username=url_username).count() == 1) if already_registered: if is_admin_user(request.user): admin_notice = "ADMIN OVERRIDE ! Can re-set everything." # can proceed to reset everything else: admin_notice = f"This former expoer '{url_username}' already has a registered email address and individual troggle access password." return reshow_disabled(request, url_username, initial_values, warning, admin_notice) logged_in = (identified_login := is_identified_user(request.user)) # used on the form if url_username: initial_values.update({"username": url_username}) form = register_form(initial=initial_values) form.fields["username"].widget.attrs["readonly"]="readonly" else: form = register_form(initial=initial_values) if request.method == "POST": form = register_form(request.POST) if form.is_valid(): print("POST VALID") # so now username and email fields are readonly un = form.cleaned_data["username"] pw= form.cleaned_data["password1"] email = form.cleaned_data["email"] expoers = User.objects.filter(username=un) # if this is a LOGONABLE user and we are not logged on # NOT just save the data ! Anyone could do that.. # we are now in a state where password should only be re-set by email token # but rather than redirect (off-putting) we just make the password fields read-only if len(expoers) > 0: form.fields["password1"].widget.attrs["readonly"]="readonly" form.fields["password2"].widget.attrs["readonly"]="readonly" # create User in the system and refresh stored encrypted user list and git commit it: updated_user = register_user(un, email, password=pw, pwhash=None) save_users(request, updated_user, email) # to do, login automatically, and redirect to control panel ? form.fields["username"].widget.attrs["readonly"]="readonly" form.fields["email"].widget.attrs["readonly"]="readonly" return render(request, "login/register.html", {"form": form, "email_stored": True, "admin_notice": admin_notice, "warning": warning}) # return HttpResponseRedirect("/accounts/login/") else: # GET pass return render(request, "login/register.html", {"form": form, "admin_notice": admin_notice, "warning": warning}) def save_users(request, updated_user, email="troggle@exposerver.expo"): f = get_encryptor() ru = [] print(f"\n + Saving users, encrypted emails, and password hashes") for u in User.objects.all(): if u.username in ["expo", "expoadmin"]: continue e_email = f.encrypt(u.email.encode("utf8")).decode() ru.append({"username":u.username, "email": e_email, "pwhash": u.password, "encrypted": True}) # print(u.username, e_email) original = f.decrypt(e_email).decode() print(f" - {u.username} - {original}") if updated_user.is_anonymous: git_string = f"troggle " else: git_string = f"{updated_user.username} <{email}>" encryptedfile = settings.EXPOWEB / ENCRYPTED_DIR / USERS_FILE try: print(f"- Rewriting the entire encrypted set of registered users to disc ") write_users(ru, encryptedfile, git_string) except: message = f'! - Users encrypted data saving failed - \n!! Permissions failure ?! on attempting to save file "{encryptedfile}"' print(message) raise return render(request, "errors/generic.html", {"message": message}) def write_users(registered_users, encryptedfile, git_string): jsondict = { "registered_users": registered_users } try: with open(encryptedfile, 'w', encoding='utf-8') as json_f: json.dump(jsondict, json_f, indent=1) except Exception as e: print(f" ! Exception dumping json <{e}>") raise commit_msg = f"Online (re-)registration of a troggle User" try: add_commit(encryptedfile, commit_msg, git_string) except Exception as e: print(f" ! Exception doing git add/commit <{e}>") raise return True class newregister_form(forms.Form): # not a model-form, just a form-form """This is the form for a new user who has not been on expo before and does notalready have a username. """ fullname = forms.CharField(strip=True, required=True, label="Forename Surname", widget=forms.TextInput( attrs={"size": 35, "placeholder": "e.g. Anathema Device", "style": "vertical-align: text-top;"} )) email = forms.CharField(strip=True, required=True, label="email", widget=forms.TextInput( attrs={"size": 35, "placeholder": "e.g. anathema@tackle_store.expo", "style": "vertical-align: text-top;"} )) def clean(self): cleaned_data = super().clean() fullname = cleaned_data.get("fullname") email = cleaned_data.get("email") users = User.objects.filter(email=email) if len(users) != 0: raise ValidationError( "Duplicate email address. Another registered user is already using this email address. Email addresses must be unique as that is how we reset forgotten passwords." ) userslug = troggle_slugify(fullname) people = Person.objects.filter(slug=userslug) if len(people) != 0: raise ValidationError( "Duplicate name. There is already a username correspondng to this Forename Surname. " + "If you have been on expo before, you need to use the other form at expo.survex.com/accounts/register/ ." ) class register_email_form(forms.Form): # not a model-form, just a form-form """The widgets are being used EVEN THOUGH we are not using form.as_p() to create the HTML form""" username = forms.CharField(strip=True, required=True, label="Username", widget=forms.TextInput( attrs={"size": 35, "placeholder": "e.g. anathema-device", "style": "vertical-align: text-top;", "readonly": "readonly"} # READONLY for when changing email )) email = forms.CharField(strip=True, required=True, label="email", widget=forms.TextInput( attrs={"size": 35, "placeholder": "e.g. anathema@potatohut.expo", "style": "vertical-align: text-top;"} )) def clean(self): cleaned_data = super().clean() email = cleaned_data.get("email") users = User.objects.filter(email=email) print(f"{len(users)=}") if len(users) > 1: # allow 0 (change) or 1 (confirm) print("ValidationError") raise ValidationError( "Duplicate email address. Another registered user is already using this email address. Email addresses must be unique as that is how we reset forgotten passwords." ) class register_form(forms.Form): # not a model-form, just a form-form """The widgets are being used EVEN THOUGH we are not using form.as_p() to create the HTML form""" username = forms.CharField(strip=True, required=True, label="Username", widget=forms.TextInput( attrs={"size": 35, "placeholder": "e.g. anathema-device", "style": "vertical-align: text-top;"} )) email = forms.CharField(strip=True, required=True, label="email", widget=forms.TextInput( attrs={"size": 35, "placeholder": "e.g. anathema@potatohut.expo", "style": "vertical-align: text-top;"} )) password1 = forms.CharField(strip=True, required=True, label="Troggle password", widget=forms.TextInput( attrs={"size": 30, "placeholder": "your new login password", "style": "vertical-align: text-top;"} )) password2 = forms.CharField(strip=True, required=True, label="Re-type your troggle password", widget=forms.TextInput( attrs={"size": 30, "placeholder": "same as the password above", "style": "vertical-align: text-top;"} ) ) def clean(self): cleaned_data = super().clean() pw1 = cleaned_data.get("password1") pw2 = cleaned_data.get("password2") if pw1 != pw2: raise ValidationError( "Retyped password does not match initial password: please fix this." ) un = cleaned_data.get("username") if un in ["expo", "expoadmin"]: raise ValidationError( "Sorry, please do not try to be clever. This username is hard-coded and you can't edit it here. We were students once, too." ) expoers = Person.objects.filter(slug=un) if len(expoers) == 0: raise ValidationError( "Sorry, this is the form for people who have already been to expo. Use the New User registration form (link above)." ) if len(expoers) != 1: raise ValidationError( "Sorry, that troggle identifier has duplicates. Contact a nerd on the Nerd email list, or (better) the Matrix website chat." ) email = cleaned_data.get("email") users = User.objects.filter(email=email) if len(users) > 1: raise ValidationError( f"Duplicate email address. Another registered user {users[0]} is already using this email address. Email addresses must be unique as that is how we reset forgotten passwords." ) if len(users) == 1: if users[0].username != un: raise ValidationError( f"Duplicate email address. Another registered user '{users[0]}' is already using this email address. Email addresses must be unique as that is how we reset forgotten passwords." )