diff --git a/Backend/BackendApp/api/models.py b/Backend/BackendApp/api/models.py index 48633ed..b846902 100644 --- a/Backend/BackendApp/api/models.py +++ b/Backend/BackendApp/api/models.py @@ -1,3 +1,9 @@ +""" +Database models for the DoRun charity run application. + +Defines the Users, donationrecord, and roles models along with +their business logic methods for registration, login, and statistics. +""" from .password import pwd from django.db import models from datetime import date @@ -9,8 +15,24 @@ from django.db import connection -# Create your models here. class Users(models.Model): + """User model representing registered participants and admins. + + Stores user profile data, authentication credentials (hashed password + salt), + role-based permissions, and activity tracking (kilometers, login attempts). + + Fields: + iduser: Primary key, manually assigned integer ID + firstname/lastname: User's name + email: Unique email address + password_hash: Binary hash of the salted password + salt: Random binary salt for password hashing + createdat: Auto-set on creation + roleid: 1=admin, 2=moderator, 3=regular user + kilometers: Total kilometers run by the user + verified: Whether the user's email has been verified + logintrys: Count of consecutive failed login attempts (locked at >5) + """ iduser = models.IntegerField(primary_key=True, null=False) firstname = models.TextField(null=False) lastname = models.TextField(null=False) @@ -22,68 +44,91 @@ class Users(models.Model): kilometers = models.IntegerField(null=False) verified = models.BooleanField() logintrys = models.IntegerField(default=0) - - def RegisterUser(first_name,last_name,email,password): - # Password validation + def RegisterUser(first_name, last_name, email, password): + """ + Register a new user in the database. + + Validates password constraints, checks for duplicate emails, + auto-assigns the next available user ID, hashes the password, + and creates the database record. + + Args: + first_name: User's first name + last_name: User's last name + email: User's email address (checked for uniqueness) + password: Plaintext password to hash and store + + Returns: + Users instance on success, None on validation failure or + if required fields are missing. + """ + # Validate password meets complexity requirements validation = pwd.checkPwdConstraints(password) - if (validation != 1): + if validation != 1: print("Password is not valid") return None - - #1. Set UserID - #Check if email already exists - double = False + + # Step 1: Determine UserID — auto-increment from max existing ID + email_exists = False UserID = None + + # Check if email already exists in the database try: - CheckForDoubleUser = Users.objects.raw("Select * From api_users Where email = "+ "'" + email + "'") - for p in CheckForDoubleUser: - double = True + duplicate_check = Users.objects.raw( + "SELECT * FROM api_users WHERE email = " + "'" + email + "'" + ) + for user_row in duplicate_check: + email_exists = True except: - double = False - print("double " + str(double)) + email_exists = False + + print("duplicate email: " + str(email_exists)) + try: - if (double == False): - #Get current highest iduser - query = "Select iduser From api_users Where iduser = (Select Max(iduser) From api_users)" - user = Users.objects.raw(query) - - #Chech if the new user is the first then id = 1 else max id + 1 - test = False - for p in user: - test = True - if (p.iduser != None): - UserID = p.iduser - UserID = UserID + 1 - elif (p.iduser == None): + if email_exists == False: + # Get current highest iduser to compute next ID + query = "SELECT iduser FROM api_users WHERE iduser = (SELECT MAX(iduser) FROM api_users)" + max_user_result = Users.objects.raw(query) + + # Check if there is at least one existing user + has_existing_user = False + for user_row in max_user_result: + has_existing_user = True + if user_row.iduser is not None: + UserID = user_row.iduser + 1 + else: UserID = 1 - print("test " + str(test)) - + + print("has existing users: " + str(has_existing_user)) + except: - print("Unexpected error ocurred!") - - #2. Password hashing - if (password != None): + print("Unexpected error occurred!") + + # Step 2: Hash the password with a random salt + if password is not None: Password_hash, Salt = pwd.PasswordHashing(password) - - #3. Set current date + + # Step 3: Set creation date to today CreatedAt = date.today() - - #4. Set RoleID = 3 aka User - RoleID = 3 - - #5. Set user-validation validation set by Link to true + + # Step 4: Default role is 3 (regular user) + RoleID = 3 + + # Step 5: Initialize defaults for new users Kilometers = 0 VerifiedUser = False - + NewUser = None - #Creat new DB entry if values are filled - print("UserID") - print(UserID) - if (UserID != None and first_name != None and last_name != None and email != None and Password_hash != None and Salt != None and CreatedAt != None and RoleID != None): + + # Create new DB entry only if all required values are populated + if (UserID is not None and first_name is not None and last_name is not None + and email is not None and Password_hash is not None + and Salt is not None and CreatedAt is not None and RoleID is not None): + print("Creating new User with ID: " + str(UserID)) NewUser = Users.objects.create( - iduser=UserID, + iduser=UserID, firstname=first_name, lastname=last_name, email=email, @@ -91,82 +136,113 @@ def RegisterUser(first_name,last_name,email,password): salt=bytearray.fromhex(Salt), createdat=CreatedAt, roleid=RoleID, - verified=VerifiedUser, - kilometers=Kilometers) + verified=VerifiedUser, + kilometers=Kilometers + ) return NewUser - # try: - # except: - # print("Error, user can't be added to DB!") else: print("Not all requirements are fulfilled to create a user") - - # if the process was denied, no NewUser is created + + # Registration denied — return None return None - - # end def - def LoginUser(email,password): - #%s is to prevent SQL-injection + def LoginUser(email, password): + """ + Authenticate a user by email and password. + + Looks up the user by email, compares the hashed password, + tracks login attempts, and locks the account after 5 failures. + + Args: + email: User's email address + password: Plaintext password to verify + + Returns: + Users instance on successful login. + -101 if password is wrong or another error occurred. + -100 if the account is locked (too many attempts). + """ + # Using %s parameterized queries to prevent SQL injection try: - #Get data to the provided email - LoginUser = Users.objects.raw("Select * From api_users Where email = %s", [email]) - for p in LoginUser: - #Init password - test = str(b'') - #If init password eq user password then trigger reset - if (str(p.password_hash) == test): - print(p.password_hash, test) - print("No password for User") + # Fetch user data for the provided email + matched_users = Users.objects.raw( + "SELECT * FROM api_users WHERE email = %s", [email] + ) + for user_row in matched_users: + # Check for empty/initialized password + empty_password = str(b'') + + # If stored password equals empty bytes, user has no password set + if str(user_row.password_hash) == empty_password: + print(user_row.password_hash, empty_password) + print("No password set for user") return -101 - - # Enter the entered password encrypt it with the salt and compare it with the pwhash from the db - Password_correct = pwd.CheckPassword(password, p.password_hash, p.salt) - - logintrys = p.logintrys - - if (Password_correct == True): - #Return LoginUser - - if (logintrys <= 5): - logintrys = 0 + + # Hash the entered password with the stored salt and compare + password_correct = pwd.CheckPassword( + password, user_row.password_hash, user_row.salt + ) + + login_attempts = user_row.logintrys + + if password_correct: + # Password matches — reset login attempts on success + if login_attempts <= 5: + login_attempts = 0 + + # Reset login counter in database via parameterized UPDATE sql = "UPDATE api_users SET logintrys = %s WHERE email = %s" - # Parameter - values = [logintrys,email] + values = [login_attempts, email] - # SQL ausführen try: with connection.cursor() as cursor: cursor.execute(sql, values) except: return -101 - return p + + return user_row else: + # Account is already locked return -100 - else: - logintrys = logintrys + 1 + # Password incorrect — increment login attempts + login_attempts = login_attempts + 1 + sql = "UPDATE api_users SET logintrys = %s WHERE email = %s" - # Parameter - values = [logintrys,email] - - # SQL ausführen + values = [login_attempts, email] + try: with connection.cursor() as cursor: cursor.execute(sql, values) - if (logintrys > 5): + + if login_attempts > 5: + # Account locked after exceeding attempt limit return -100 return -101 except: return -101 - + except: - print("Error") - - + print("Error during login") + - - class donationrecord(models.Model): + """Donation/sponsor record linking donors to runners. + + Tracks donation pledges: a sponsor (identified by name/email/address) + pledges an amount per kilometer or a fixed amount for a specific runner. + + Fields: + donationrecid: Primary key, manually assigned integer ID + iduser: Foreign key reference to the sponsored runner + firstname/lastname/email: Sponsor contact information + street/housenr/postcode: Sponsor address + donation: Pledged amount (per km if fixedamount=False, total if True) + fixedamount: Whether the donation is a flat amount (True) or per-km (False) + createdat: Auto-set on creation + verified: Whether the donation has been verified + iscertreq: Whether a donation certificate is requested + """ donationrecid = models.IntegerField(primary_key=True, null=False) iduser = models.IntegerField(null=False) firstname = models.TextField(null=False) @@ -180,151 +256,191 @@ class donationrecord(models.Model): createdat = models.DateTimeField(auto_now_add=True, null=False) verified = models.BooleanField(null=True) iscertreq = models.BooleanField(null=False) - + def GetUserStats(Userid): - #Get Userdata for Welcome Screen - UserName = Users.objects.raw("Select iduser, firstname, lastname, email From api_users Where iduser = %s", [Userid]) - + """ + Build dashboard data for a specific user. + + Retrieves the user's profile, all their donation records, + and calculates total donations and total kilometers. + + For fixed-amount donations: only counted if the user has run at least 1 km. + For per-km donations: donation amount multiplied by user's kilometers. + + Args: + Userid: The user's ID + + Returns: + List of dicts with user info, totals, and donation entry details. + Returns False if data cannot be computed. + """ + # Fetch user profile data + UserName = Users.objects.raw( + "SELECT iduser, firstname, lastname, email FROM api_users WHERE iduser = %s", + [Userid] + ) + for row in UserName: UserFirstname = row.firstname UserLastname = row.lastname UserEmail = row.email - - #Get donationrecord for the loggedin user - UserEntrys = donationrecord.objects.raw("Select * From api_donationrecord Where iduser = %s", [Userid]) - #Get Userdat - UserData = Users.objects.raw("Select * From api_users Where iduser = %s", [Userid]) - + + # Fetch all donation records for this user + UserDonations = donationrecord.objects.raw( + "SELECT * FROM api_donationrecord WHERE iduser = %s", [Userid] + ) + + # Fetch complete user data (needed for kilometers) + UserData = Users.objects.raw( + "SELECT * FROM api_users WHERE iduser = %s", [Userid] + ) + TotalDonations = 0 TotalKilometers = 0 - #Get Total amount for Donations and Total Kilomers + + # Get total kilometers for the user for row in UserData: kilometers = row.kilometers - + try: - for row in UserEntrys: + for row in UserDonations: if row.verified == True: - #Calculate total Donations - if (row.fixedamount == True): - # only add up the fixed dons if the user has atleast on km - if (kilometers > 0): + if row.fixedamount == True: + # Fixed donations only count if the user has at least 1 km + if kilometers > 0: TotalDonations += row.donation else: + # Per-km donations: amount * kilometers run TotalDonations += (row.donation * kilometers) - except: print("Can't calculate without data") - + data = [] - #Safe evaluation + + # Add summary row with totals data.append({ "UserFirstname": UserFirstname, "UserLastname": UserLastname, "UserEmail": UserEmail, "TotalDonations": TotalDonations, - "TotalKilometers": kilometers}) - - #if (kilometers): - # Schleife durch die UserEntrys-Objekte - for obj in UserEntrys: - data.append({ - "donoid" : obj.donationrecid, - "firstname": obj.firstname, - "lastname": obj.lastname, - "email": obj.email, - "street": obj.street, - "housenr":obj.housenr, - "postcode": obj.postcode, - "donation": obj.donation, - "fixedamount": obj.fixedamount, + "TotalKilometers": kilometers + }) + + # Add individual donation record details + for donation_entry in UserDonations: + data.append({ + "donoid": donation_entry.donationrecid, + "firstname": donation_entry.firstname, + "lastname": donation_entry.lastname, + "email": donation_entry.email, + "street": donation_entry.street, + "housenr": donation_entry.housenr, + "postcode": donation_entry.postcode, + "donation": donation_entry.donation, + "fixedamount": donation_entry.fixedamount, "createdat": date.today(), - "verified": obj.verified, + "verified": donation_entry.verified, "Kilometer": kilometers, - "iscertreq": obj.iscertreq, - }) + "iscertreq": donation_entry.iscertreq, + }) - #return JSON return data - + def GetAdminStats(Userid): - #vars - Message = "Permission denied" + """ + Build admin dashboard data. + + Retrieves aggregate donation statistics across all records, + plus the admin's own profile info. For admin users (roleid < 3), + also returns the full user list. + + Fixed donations are only counted for runners with at least 1 km. + + Args: + Userid: The admin's user ID + + Returns: + List of dicts with donation totals, admin info, and + (for admins) full user list. + """ data = [] - #Get Userdata for welcome screen - UserName = Users.objects.raw("Select iduser, firstname, lastname, email From api_users Where iduser = %s", [Userid]) - - Super_Data = donationrecord.objects.all() - - TDonoF = 0 - TDono = 0 - for Super_row in Super_Data: - UserData = Users.objects.raw("Select iduser, kilometers From api_users Where iduser = %s",[Super_row.iduser]) - if (Super_row.fixedamount == True): - # only add up the fixed dons if the user has atleast on km + + # Fetch admin user profile + UserName = Users.objects.raw( + "SELECT iduser, firstname, lastname, email FROM api_users WHERE iduser = %s", + [Userid] + ) + + # Get all donation records for aggregate calculations + AllDonations = donationrecord.objects.all() + + TDonoF = 0 # Total of fixed donations + TDono = 0 # Total of per-km donations + + for donation_row in AllDonations: + # Fetch the runner's kilometers for this donation + UserData = Users.objects.raw( + "SELECT iduser, kilometers FROM api_users WHERE iduser = %s", + [donation_row.iduser] + ) + + if donation_row.fixedamount == True: + # Fixed donations only count if runner has at least 1 km for user in UserData: if user.kilometers > 0: - TDonoF += Super_row.donation + TDonoF += donation_row.donation else: + # Per-km donations: amount * runner's kilometers for user in UserData: - TDono = TDono + (Super_row.donation * user.kilometers) - + TDono = TDono + (donation_row.donation * user.kilometers) + + # Extract admin profile fields for row in UserName: UserFirstname = row.firstname UserLastname = row.lastname UserEmail = row.email Roleid = row.roleid - if (Roleid < 3): - Message = "Permission granted" - - #Safe evaluation + message = "Permission denied" + if Roleid < 3: + message = "Permission granted" + + # Add summary row data.append({ "DonoFix": TDonoF, "DonoTotal": TDono, "UserFirstname": UserFirstname, "UserLastname": UserLastname, "UserEmail": UserEmail, - "Message": Message}) - - - if (Roleid == 1 or Roleid == 2): - #Get Userdat - UserData = Users.objects.all().order_by('iduser') - - for row in UserData: - - data.append({"userid":row.iduser, - "firstname": row.firstname, - "lastname": row.lastname, - "email": row.email, - "createdat": row.createdat, - "verified": row.verified, - "kilometers": row.kilometers}) - + "Message": message + }) + + # Admins (roleid 1 or 2) also receive the full user list + if Roleid == 1 or Roleid == 2: + # Fetch all users ordered by ID + AllUsers = Users.objects.all().order_by('iduser') + + for row in AllUsers: + data.append({ + "userid": row.iduser, + "firstname": row.firstname, + "lastname": row.lastname, + "email": row.email, + "createdat": row.createdat, + "verified": row.verified, + "kilometers": row.kilometers + }) + return data - # end def - + + def roles(): - roleid = models.IntegerField(primary_key=True,null=False) + """Role model definition (placeholder — not yet implemented as a DB table).""" + roleid = models.IntegerField(primary_key=True, null=False) rolename = models.TextField(null=False) class CustomBackend(BaseBackend): + """Custom authentication backend stub for Django auth integration.""" def get_user(self, user_id): return Users(id=user_id, username='benutzername') - -# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⡈⠛⢉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⢿⣿⣿⣿⣿⣿⠀⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⢰⣿⡏⠀⢸⣿⣿⣿⣿⡇⢸⣷⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⣼⣿⠁⠀⢸⣿⣿⣿⣿⠁⠀⠙⠻⢿⣿⣶⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠛⠋⠀⠀⠸⣿⣿⣿⡏⠀⠀⠀⠀⠀⠈⠉⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣄⠙⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣦⠈⢿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⡟⠀⠀⠻⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⠟⠁⠀⠀⠀⠘⢿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⢾⣿⠟⠁⠀⠀⠀⠀⠀⠀⠈⢻⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀ -#⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ \ No newline at end of file diff --git a/Backend/BackendApp/api/password.py b/Backend/BackendApp/api/password.py index 9ee9bb0..0c0ade4 100644 --- a/Backend/BackendApp/api/password.py +++ b/Backend/BackendApp/api/password.py @@ -1,3 +1,9 @@ +""" +Password management utilities for the DoRun application. + +Provides password hashing, validation, secure generation, and +database update methods used by views and models. +""" from hashlib import sha256 from multiprocessing import connection import random @@ -6,92 +12,192 @@ from . import models - class pwd(): - def SetPassword(email,Password): + """Static utility class for password operations.""" + + def SetPassword(email, Password): + """ + Reset a user's password and salt via email. + + Generates a new random salt, hashes the new password, + and updates the database with both. + + Args: + email: User's email address + Password: New plaintext password + + Returns: + Tuple of (HTTP_status, message_string) + """ Message = "" Status = 401 try: + # Generate new salt and hash for the password Password_hash, Salt = pwd.PasswordHashing(Password) print(Password_hash, Salt) - - # SQL-Abfrage + + # Parameterized UPDATE query to prevent SQL injection sql = "UPDATE api_users SET password_hash = %s, salt = %s WHERE email = %s" - # Parameter values = [bytearray.fromhex(Password_hash), bytearray.fromhex(Salt), email] - # SQL ausführen + # Execute the update with connection.cursor() as cursor: cursor.execute(sql, values) - - Message = "Password changed succesfully" + + Message = "Password changed successfully" Status = 200 except: - Message = "Cant set password!" - + Message = "Cannot set password!" + return Status, Message - - def SetJustPasswordWith_iduser(iduser,Password): + def SetJustPasswordWith_iduser(iduser, Password): + """ + Change a user's password by ID, preserving the existing salt. + + Unlike SetPassword(), this reuses the user's current salt, + so only the password hash is updated. Used when the user + knows their old password and just wants to change it. + + Args: + iduser: User's ID + Password: New plaintext password + + Returns: + Tuple of (HTTP_status, message_string) + """ Message = "" Status = 401 - + + # Validate password complexity before proceeding match pwd.checkPwdConstraints(Password): case -1: - Message = "Password muss ein Buchstaben, eine Zahl und ein Sonderzeichen enthalten!" + Message = "Password must contain a letter, a number, and a special character!" Status = 401 return Status, Message case 0: - Message = "Password muss mindestens 8 Zeichen lang sein!" + Message = "Password must be at least 8 characters long!" Status = 401 return Status, Message + print("Password is valid") + try: + # Retrieve the user's existing salt (do not generate a new one) salt = models.Users.objects.get(iduser=iduser).salt - print("salt: ", salt) + print("salt:", salt) + + # Hash the new password with the existing salt Password_hash = pwd.PasswordSetJustPassword(password=Password, salt=salt) + print("Password_hash:", Password_hash) - print("Password_hash: ", Password_hash) - # SQL-Abfrage + # Update only the password hash in the database sql = "UPDATE api_users SET password_hash = %s WHERE iduser = %s" - # Parameter values = [bytearray.fromhex(Password_hash), iduser] - # SQL ausführen with connection.cursor() as cursor: cursor.execute(sql, values) - - Message = "Password changed succesfully" + + Message = "Password changed successfully" Status = 200 except: - Message = "Cant set password!" - + Message = "Cannot set password!" + return Status, Message - - # Method to create string of random chars def RandChars(size=30, chars=string.ascii_uppercase + string.digits): + """ + Generate a random string of specified length. + + Used for creating random salts. + + Args: + size: Length of the generated string (default 30) + chars: Character pool to draw from (default: uppercase + digits) + + Returns: + Random string of length `size` + """ return ''.join(random.choice(chars) for _ in range(size)) def PasswordHashing(password): - SaltText = pwd.RandChars() # Generiert zufällige Zeichenabfolge - Salt = sha256(SaltText.encode('utf-8')).digest().hex() # Erstellt den Hash des Salts - Password_Hash = sha256((password + Salt).encode('utf-8')).digest() # Verschlüsselung des Passwords und Salt - return Password_Hash.hex(), Salt # Rückgabe + """ + Hash a password with a new random salt. + + Steps: + 1. Generate a random salt string + 2. SHA-256 hash the salt string to produce the salt digest + 3. SHA-256 hash the concatenation of password + salt digest + + Args: + password: Plaintext password to hash + + Returns: + Tuple of (password_hash_hex, salt_hex) — both as hex strings + """ + # Generate random characters for the salt + SaltText = pwd.RandChars() + + # Hash the salt text to produce the final salt + Salt = sha256(SaltText.encode('utf-8')).digest().hex() + + # Hash the password concatenated with the salt + Password_Hash = sha256((password + Salt).encode('utf-8')).digest() + + return Password_Hash.hex(), Salt def convertSaltAndHash(salt, hash): - return bytearray.fromhex(salt), bytearray.fromhex(hash) + """ + Convert hex-encoded salt and hash strings to bytearray format. + + Used when storing password data in the database BinaryField. + + Args: + salt: Hex string of the salt + hash: Hex string of the password hash + + Returns: + Tuple of (salt_bytearray, hash_bytearray) + """ + return bytearray.fromhex(salt), bytearray.fromhex(hash) - # Sets only the password not the salt def PasswordSetJustPassword(password, salt): - original_hex_string = salt.hex() - Password_Hash = sha256((password + original_hex_string).encode('utf-8')).digest() - return Password_Hash.hex() + """ + Hash a password using an existing salt (no new salt generated). + + Used when changing a password without rotating the salt. + + Args: + password: Plaintext password + salt: Existing salt as bytearray + Returns: + Hex string of the resulting password hash + """ + # Convert bytearray salt to hex string for hashing + salt_hex = salt.hex() + # Hash password concatenated with the existing salt + Password_Hash = sha256((password + salt_hex).encode('utf-8')).digest() + + return Password_Hash.hex() def checkPwdConstraints(input_string): - # 1 = valid, 0 = to short, -1 = missing later/digit/special char + """ + Validate password complexity requirements. + + Checks that the password: + - Is at least 8 characters long + - Contains at least one letter + - Contains at least one digit + - Contains at least one special character + + Args: + input_string: Password string to validate + + Returns: + 1 if valid, 0 if too short, -1 if missing required character types + """ if len(input_string) < 8: return 0 @@ -103,19 +209,51 @@ def checkPwdConstraints(input_string): return 1 else: return -1 - def Generate_secure_password(length): + """ + Generate a cryptographically random password. + + Uses letters, digits, and a restricted set of special characters. + + Args: + length: Desired password length (minimum 8) + + Returns: + Random password string + + Raises: + ValueError if length < 8 + """ if length < 8: - raise ValueError("Passwortlänge sollte mindestens 8 Zeichen betragen.") - - allowed_special_chars = "!_-@%" # Einschränkung auf 2 Sonderzeichen + raise ValueError("Password length must be at least 8 characters.") + + # Restricted set of special characters for compatibility + allowed_special_chars = "!_-@%" characters = string.ascii_letters + string.digits + allowed_special_chars password = ''.join(random.choice(characters) for _ in range(length)) return password - - def CheckPassword(EnteredPwd, password, salt): - EnteredPwdHash = sha256((EnteredPwd + salt.hex()).encode('utf-8')).digest() # Bildet den Hash nach - is_valid = EnteredPwdHash == password # Vergleicht den Gespeicherten und Neu generierten Hash - return is_valid # Gibt einen Boolschen Wert zurück + def CheckPassword(EnteredPwd, password, salt): + """ + Verify a plaintext password against a stored hash and salt. + + Steps: + 1. Hash the entered password with the stored salt + 2. Compare the result with the stored password hash + + Args: + EnteredPwd: Plaintext password to verify + password: Stored password hash as bytearray + salt: Stored salt as bytearray + + Returns: + True if the password matches, False otherwise + """ + # Hash the entered password with the stored salt + EnteredPwdHash = sha256((EnteredPwd + salt.hex()).encode('utf-8')).digest() + + # Compare the newly computed hash with the stored hash + is_valid = EnteredPwdHash == password + + return is_valid diff --git a/Backend/BackendApp/mail/mail_handle.py b/Backend/BackendApp/mail/mail_handle.py index 807e7b3..524d4db 100644 --- a/Backend/BackendApp/mail/mail_handle.py +++ b/Backend/BackendApp/mail/mail_handle.py @@ -1,8 +1,11 @@ -# import smtplib +""" +Mail handling module for the DoRun charity run application. + +Provides functions for sending verification emails to users and donors, +sponsor notification emails, runner summary emails, and password reset emails. +Uses the MailSender class for SMTP communication. +""" from django.shortcuts import get_object_or_404 -# from email.mime.multipart import MIMEMultipart -# from email.mime.text import MIMEText -# import pandas as pd from . import views from BackendApp import settings as set from django.http import HttpResponse @@ -14,41 +17,64 @@ from django.views.decorators.csrf import csrf_exempt, csrf_protect -# Implement interface for mail here - -# Then implement class implemented by the interface @csrf_protect def sendUserVerifyMail(request, UserID, frontendDomain): - user = get_object_or_404(Users, iduser=UserID) # Get single user by ID + """ + Send a verification email to a newly registered user. + + Generates an HTML email with a verification link containing a token + that the user must click to confirm their email address. + + Args: + request: Django HTTP request object + UserID: ID of the user to verify + frontendDomain: Base URL of the frontend (used in verification link) + """ + user = get_object_or_404(Users, iduser=UserID) - mail = MailSender() # Connect to mail server and initialize class + # Initialize mail server connection + mail = MailSender() + # Generate HTML and plain-text email content with verification link mailtext_html, mailtext_plain = views.UserAuth( request=request, UserID=UserID, user=user, frontendDomain=frontendDomain - ) # Load HTML and plain text + ) - # Initiate sending the mail + # Send the verification email mail.SendMail( pReceiver=user.email, - pSubject="Runner Registration - Charity Run", # Later insert configurable variables here + pSubject="Runner Registration - Charity Run", pMailText=mailtext_html, pPlainText=mailtext_plain, ) - mail.CloseConnection() # Disconnect server connection + # Close the SMTP connection + mail.CloseConnection() - return HttpResponse(f"Mail sent to {user.lastname}, {user.firstname}") # Send HTTP response + return HttpResponse(f"Mail sent to {user.lastname}, {user.firstname}") @csrf_protect def sendDonationVerifyMail(request, UserID, DonationId, frontendDomain): - user = get_object_or_404(Users, iduser=UserID) # Get single user by ID - donRec = get_object_or_404(donationrecord, donationrecid=DonationId) # Get single donation record by ID + """ + Send a verification email for a donation/sponsorship record. + + Similar to user verification, but for donation confirmations. + Contains a link the sponsor must click to verify their pledge. - mail = MailSender() # Connect to mail server + Args: + request: Django HTTP request object + UserID: ID of the sponsored runner + DonationId: ID of the donation record + frontendDomain: Base URL of the frontend + """ + user = get_object_or_404(Users, iduser=UserID) + donRec = get_object_or_404(donationrecord, donationrecid=DonationId) + + mail = MailSender() mailtext_html, mailtext_plain = views.DonRecAuth( request=request, @@ -59,7 +85,6 @@ def sendDonationVerifyMail(request, UserID, DonationId, frontendDomain): frontendDomain=frontendDomain ) - # Initiate sending the mail mail.SendMail( pReceiver=donRec.email, pSubject=f"Charity Run 2025 | Sponsor Registration: {user.firstname}", @@ -67,13 +92,27 @@ def sendDonationVerifyMail(request, UserID, DonationId, frontendDomain): pPlainText=mailtext_plain, ) - mail.CloseConnection() # Disconnect server connection + mail.CloseConnection() - return HttpResponse(f"Mail sent to {user.lastname}, {user.firstname}") # Send HTTP response + return HttpResponse(f"Mail sent to {user.lastname}, {user.firstname}") -# Template for a list object to store sponsor data class SponsData: + """ + Data container for sponsor information used in email templates. + + Calculates the total donation amount based on whether the donation + is fixed or per-kilometer. + + Attributes: + firstname: Sponsor's first name + lastname: Sponsor's last name + kilometer: Kilometers run by the sponsored runner + FixedDonation: Whether this is a fixed-amount donation + Donation: The pledged donation amount + DonationTotal: Computed total (0 for fixed if 0 km, else amount; + for per-km: amount * kilometers) + """ firstname: str lastname: str kilometer: int @@ -88,19 +127,29 @@ def __init__(self, pFirstname, pLastname, pKm, pFixedDon, pDon): self.FixedDonation = pFixedDon self.Donation = pDon - # Check if it's a fixed donation + # Calculate total donation: fixed donations require at least 1 km if pFixedDon: - # If it's fixed, the runner must run at least 1 km to receive the donation self.DonationTotal = 0 if pKm > 0: self.DonationTotal = pDon else: + # Per-km donation: amount * kilometers self.DonationTotal = pDon * pKm -# Binary search for user object -# can be deleted in future, bc the orm has a method for this def BinarySearchUsers(users, id): + """ + Binary search for a user object by ID in a sorted list. + + Note: This can be replaced by Django ORM's built-in lookup in the future. + + Args: + users: List of Users objects sorted by iduser + id: User ID to find + + Returns: + Index of the user in the list, or -1 if not found + """ min = 0 max = len(users) while min + 1 < max: @@ -115,43 +164,55 @@ def BinarySearchUsers(users, id): return -1 -# Load sponsor information def loadSponsorInfo(request, DonRecEmail, users): - donRec = donationrecord.objects.filter(email=DonRecEmail) # Get all donation records with the email + """ + Build email content for a sponsor showing all their sponsored runners. + + For a given sponsor email, finds all donation records, looks up each + sponsored runner's progress, and prepares HTML email data. + + Args: + request: Django HTTP request object + DonRecEmail: Sponsor's email address + users: Pre-fetched list of all Users (sorted by ID for binary search) + + Returns: + HTML email body as string + """ + # Get all donation records for this sponsor email + donRec = donationrecord.objects.filter(email=DonRecEmail) set.logger.print(donRec) - # Initialize variables - mail = None TotalDonation: float = 0 TotalKilometers = 0 data = [] - # Iterate over donation entries to gather runner data - for val in donRec: - # Binary search user list to find user - usersIndex = BinarySearchUsers(users, val.iduser) + # Iterate over each sponsored runner + for donation in donRec: + # Find the runner using binary search on the sorted user list + usersIndex = BinarySearchUsers(users, donation.iduser) if usersIndex < 0: - logger.print(f"Warning: User ID {val.iduser} doesn't exist") + logger.print(f"Warning: User ID {donation.iduser} doesn't exist") continue Kilometers = users[usersIndex].kilometers - # Create data entry + # Build sponsor data entry for the runner dataRec = SponsData( pFirstname=users[usersIndex].firstname, pLastname=users[usersIndex].lastname, pKm=Kilometers, - pFixedDon=val.fixedamount, - pDon=val.donation + pFixedDon=donation.fixedamount, + pDon=donation.donation ) - # Calculate total donations and total kilometers of all sponsored runners + # Accumulate totals across all sponsored runners TotalDonation += dataRec.DonationTotal TotalKilometers += Kilometers data.append(dataRec) - # Prepare JSON-like data for rendering + # Prepare template context context = { 'name': f"{donRec[0].firstname} {donRec[0].lastname}", 'Amount': len(donRec), @@ -162,22 +223,29 @@ def loadSponsorInfo(request, DonRecEmail, users): set.logger.print(context) - # Pass data, request, and template to generate the email content - return views.RenderMailText(context=context, request=request, template_name="SponsorInfo.html") + # Render the HTML email from template + return views.RenderMailText( + context=context, request=request, template_name="SponsorInfo.html" + ) -# Send sponsor info emails to all sponsors def sendSponsorInfo(request): - users = Users.objects.order_by("iduser") # Get all users sorted by ID (for binary search) + """ + Send donation overview emails to all unique sponsors. + + Iterates over distinct sponsor email addresses, generates personalized + summaries of all runners they sponsor, and sends them via email. + """ + # Get all users sorted by ID (required for binary search in loadSponsorInfo) + users = Users.objects.order_by("iduser") - # Get distinct email addresses + # Get distinct sponsor email addresses eMailArr = donationrecord.objects.values_list('email', flat=True).distinct() set.logger.print(users) - mail = MailSender() # Connect to mail server + mail = MailSender() - # Iterate over email addresses for eMail in eMailArr: mail.SendMail( pReceiver=eMail, @@ -186,34 +254,45 @@ def sendSponsorInfo(request): pPlainText="" ) - mail.CloseConnection() # Disconnect from mail server + mail.CloseConnection() - return HttpResponse("Mails sent!") # Send HTTP response + return HttpResponse("Mails sent!") -# Load runner information emails def loadRunnerInfo(request, donRecs, user, RunnerAmount, EventKilometers, EventTotal): - mail = None + """ + Build email content for a runner showing their sponsorship summary. + + Args: + request: Django HTTP request object + donRecs: Queryset of all donation records + user: The runner's Users object + RunnerAmount: Total number of runners in the event + EventKilometers: Total kilometers across all runners + EventTotal: Total donation amount across the event + + Returns: + HTML email body as string + """ Kilometers = user.kilometers SponsorAmount = donRecs.filter(iduser=user.iduser).count() data = [] RunnerTotal: float = 0 - # Iterate over donation records with user ID - for val in donRecs.filter(iduser=user.iduser): - # Create sponsor data entry + # Iterate over this runner's specific donations + for donation in donRecs.filter(iduser=user.iduser): dataRec = SponsData( - pFirstname=val.firstname, - pLastname=val.lastname, + pFirstname=donation.firstname, + pLastname=donation.lastname, pKm=Kilometers, - pFixedDon=val.fixedamount, - pDon=val.donation + pFixedDon=donation.fixedamount, + pDon=donation.donation ) - RunnerTotal += dataRec.DonationTotal # Add runner’s donation income - data.append(dataRec) # Add donation record to list + RunnerTotal += dataRec.DonationTotal + data.append(dataRec) - # Prepare context data for template + # Prepare template context context = { 'name': f"{user.firstname} {user.lastname}", 'RunnerKilometers': Kilometers, @@ -227,21 +306,27 @@ def loadRunnerInfo(request, donRecs, user, RunnerAmount, EventKilometers, EventT set.logger.print(context) - # Pass context to generate HTML mail body - return views.RenderMailText(context=context, request=request, template_name="RunnerInfo.html") + return views.RenderMailText( + context=context, request=request, template_name="RunnerInfo.html" + ) def sendRunnerInfo(request): - users = Users.objects.filter(verified=True, roleid=3) # Get verified runners (roleid == 3) + """ + Send summary emails to all verified runners. + + Each runner receives an overview of their sponsors, total donations, + and event-wide statistics. + """ + # Get verified runners only (roleid == 3) + users = Users.objects.filter(verified=True, roleid=3) donRecs = donationrecord.objects.all() - RunnerAmount = users.count() # Total number of runners - EventKilometers = users.aggregate(total_km=Sum('kilometers'))['total_km'] or 0 # Total km of all runners + RunnerAmount = users.count() + EventKilometers = users.aggregate(total_km=Sum('kilometers'))['total_km'] or 0 EventTotal = 0 - set.logger.print("test") - - # Calculate total donation amount + # Calculate total event donation amount for donRec in donRecs.filter(verified=True): if donRec.fixedamount: EventTotal += donRec.donation @@ -251,11 +336,12 @@ def sendRunnerInfo(request): try: km = users.get(iduser=donRec.iduser).kilometers except Exception as es: - set.logger.print("error ", es) + set.logger.print("error", es) EventTotal += donRec.donation * km mail = MailSender() + for usr in users: mail.SendMail( pReceiver=usr.email, @@ -277,19 +363,31 @@ def sendRunnerInfo(request): def sendForgotPwd(request, email, frontendDomain): - mail = MailSender() # Connect to mail server and initialize class + """ + Send a password reset email to a user. + + Looks up the user by email (case-insensitive), generates a password + reset link, and sends it via email. - # Convert email to lowercase for case-insensitive matching + Args: + request: Django HTTP request object + email: User's email address + frontendDomain: Base URL of the frontend (used in reset link) + """ + mail = MailSender() + + # Case-insensitive email lookup user = get_object_or_404(Users, email__iexact=email) - # Initiate sending the mail mail.SendMail( pReceiver=email, pSubject="Charity Run 2025 | User Login", - pMailText=views.ForgotPwd_MailBody(request=request, user=user, frontendDomain=frontendDomain, email=email), + pMailText=views.ForgotPwd_MailBody( + request=request, user=user, frontendDomain=frontendDomain, email=email + ), pPlainText="" ) - mail.CloseConnection() # Disconnect server connection + mail.CloseConnection() return HttpResponse(f"Mail sent to email: {email} to change password")