Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from app.controllers.article_controller import article_bp
from app.controllers.comment_controller import comment_bp
from app.controllers.login_controller import login_bp
from app.controllers.registration_controller import registration_bp
from config.configuration_variables import env_vars
from database.database_setup import db_session

Expand Down Expand Up @@ -36,6 +37,7 @@ def initialize_flask_application() -> Flask:
app.secret_key = env_vars.secret_key

app.register_blueprint(login_bp)
app.register_blueprint(registration_bp)
app.register_blueprint(article_bp)
app.register_blueprint(comment_bp)
app.teardown_appcontext(shutdown_session)
Expand Down
3 changes: 3 additions & 0 deletions app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Role(str, Enum):
"""
Enum representing user roles within the application.
"""

ADMIN = "admin"
AUTHOR = "author"
USER = "user"
Expand All @@ -14,6 +15,7 @@ class SessionKey(str, Enum):
"""
Enum representing keys used in the Flask session.
"""

USER_ID = "user_id"
ROLE = "role"
USERNAME = "username"
Expand All @@ -23,4 +25,5 @@ class PaginationConfig:
"""
Configuration for pagination settings.
"""

ARTICLES_PER_PAGE = 10
41 changes: 41 additions & 0 deletions app/controllers/registration_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from flask import Blueprint, flash, redirect, render_template, request, url_for
from werkzeug.wrappers import Response

from app.services.registration_service import RegistrationService
from database.database_setup import db_session

registration_bp = Blueprint("registration", __name__)


@registration_bp.route("/registration-page")
def render_registration_page() -> str:
"""
Renders the registration page.

Returns:
str: The rendered HTML template for the registration page.
"""
return render_template("registration.html")


@registration_bp.route("/create-account", methods=["POST"])
def submit_registration_form() -> Response:
"""
Handles user account creation.

Returns:
Response: A redirect back to the login page with a success or error flash message.
"""
registration_service = RegistrationService(db_session)
username = str(request.form.get("username") or "")
password = str(request.form.get("password") or "")
email = str(request.form.get("email") or "")

result = registration_service.create_account(username=username, password=password, email=email)

if isinstance(result, str):
flash(result)
return redirect(url_for("registration.render_registration_page"))

flash("Account created successfully.")
return redirect(url_for("login.render_login_page"))
4 changes: 2 additions & 2 deletions app/models/account_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Account(Base):
account_id (int): Unique identifier for the account (Primary Key).
account_username (str): Unique username used for authentication.
account_password (str): Securely hashed password string.
account_email (str | None): Optional email address for the user.
account_email (str): Unique and mandatory email address for the user.
account_role (str): Permissions role ('admin', 'author', or 'user').
account_created_at (datetime): Automated timestamp of account creation.
articles (list[Article]): Collection of articles authored by this account.
Expand All @@ -33,7 +33,7 @@ class Account(Base):
account_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
account_username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
account_password: Mapped[str] = mapped_column(Text, nullable=False)
account_email: Mapped[str | None] = mapped_column(Text)
account_email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
account_role: Mapped[str] = mapped_column(Text, nullable=False)
account_created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())

Expand Down
2 changes: 1 addition & 1 deletion app/services/article_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def __init__(self, session: Session | scoped_session[Session]):

Args:
session (Session | scoped_session[Session]): The SQLAlchemy database session.
"""
"""
self.session = session

def get_all_ordered_by_date(self) -> Sequence[Article]:
Expand Down
2 changes: 1 addition & 1 deletion app/services/login_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ def authenticate_user(self, username: str, password: str) -> Account | None:
user = self.session.execute(query).scalar_one_or_none()

if user and user.account_password == password:
return user
return user

return None
56 changes: 56 additions & 0 deletions app/services/registration_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, scoped_session

from app.models.account_model import Account


class RegistrationService:
"""
Service class responsible for handling user registration and account creation logic.
"""

def __init__(self, session: Session | scoped_session[Session]):
"""
Initialize the service with a database session (Dependency Injection).

Args:
session (Session | scoped_session[Session]): The SQLAlchemy database session to use for queries.
"""
self.session = session

def create_account(self, username: str, password: str, email: str) -> Account | str:
"""
Creates a new user account with the default 'user' role if the username and email are not already taken.

Args:
username (str): The username for the new account.
password (str): The plaintext password for the new account.
email (str): The email address for the new account.

Returns:
Account | str: The newly created Account instance, or an error message string if creation fails.
"""
username_taken_message = "This username is already taken."
email_taken_message = "This email is already taken."

existing_username_query = select(Account).where(Account.account_username == username)
existing_username = self.session.execute(existing_username_query).scalar_one_or_none()

if existing_username:
return username_taken_message

existing_email_query = select(Account).where(Account.account_email == email)
existing_email = self.session.execute(existing_email_query).scalar_one_or_none()

if existing_email:
return email_taken_message

new_account = Account(
account_username=username,
account_password=password,
account_email=email,
account_role="user",
)
self.session.add(new_account)
self.session.commit()
return new_account
74 changes: 37 additions & 37 deletions app/templates/login.html
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
<!doctype html>
<html>
<head>
<title>Connexion Blog</title>
</head>
<body>
<div style="width: 300px; margin: 50px auto">
<h2>Connexion</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<p style="color: red">{{ messages[0] }}</p>
{% endif %}
{% endwith %}

<form action="{{ url_for('login.login_authentication') }}" method="POST">
<input
type="text"
name="username"
placeholder="Nom d'utilisateur"
required
style="width: 100%; margin-bottom: 10px"
/>
<input
type="password"
name="password"
placeholder="Mot de passe"
required
style="width: 100%; margin-bottom: 10px"
/>
<button type="submit" style="width: 100%">Se connecter</button>
</form>

<p style="text-align: center; margin-top: 20px;">
<a href="{{ url_for('article.list_articles') }}">Retour à l'accueil</a>
</p>
</div>
</body>
</html>

<head>
<title>Connexion Blog</title>
</head>

<body>
<div style="width: 300px; margin: 50px auto">
<h2>Connexion</h2>

{% with messages = get_flashed_messages() %}
{% if messages %}
<p style="color: red">{{ messages[0] }}</p>
{% endif %}
{% endwith %}

<form action="{{ url_for('login.login_authentication') }}" method="POST">
<input type="text" name="username" placeholder="Nom d'utilisateur" required
style="width: 100%; margin-bottom: 10px" />
<input type="password" name="password" placeholder="Mot de passe" required
style="width: 100%; margin-bottom: 10px" />
<button type="submit" style="width: 100%">Se connecter</button>
</form>

<hr style="margin: 20px 0; border: 1px solid #ccc;">

<h3>Pas encore de compte ?</h3>
<a href="{{ url_for('registration.render_registration_page') }}"
style="display: block; width: 100%; text-align: center; text-decoration: none; padding: 5px 0; border: 1px solid #ccc; background-color: #f9f9f9; color: black;">Créer
un compte</a>

<p style="text-align: center; margin-top: 20px;">
<a href="{{ url_for('article.list_articles') }}">Retour à l'accueil</a>
</p>
</div>
</body>

</html>
34 changes: 34 additions & 0 deletions app/templates/registration.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!doctype html>
<html>

<head>
<title>Inscription Blog</title>
</head>

<body>
<div style="width: 300px; margin: 50px auto">
<h2>Créer un compte</h2>

{% with messages = get_flashed_messages() %}
{% if messages %}
<p style="color: red">{{ messages[0] }}</p>
{% endif %}
{% endwith %}

<form action="{{ url_for('registration.submit_registration_form') }}" method="POST">
<input type="text" name="username" placeholder="Nouveau nom d'utilisateur" required
style="width: 100%; margin-bottom: 10px" />
<input type="email" name="email" placeholder="Adresse email" required
style="width: 100%; margin-bottom: 10px" />
<input type="password" name="password" placeholder="Nouveau mot de passe" required
style="width: 100%; margin-bottom: 10px" />
<button type="submit" style="width: 100%">S'inscrire</button>
</form>

<p style="text-align: center; margin-top: 20px;">
<a href="{{ url_for('login.render_login_page') }}">Retour à la connexion</a>
</p>
</div>
</body>

</html>
1 change: 1 addition & 0 deletions config/configuration_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
load_dotenv(BASE_DIR / ".env")
load_dotenv(BASE_DIR / ".env.test")


def get_env_variable(name: str) -> str:
"""
Retrieves an environment variable or raises a RuntimeError if missing.
Expand Down
4 changes: 4 additions & 0 deletions migrations/V6__make_account_email_mandatory_and_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE accounts
ALTER COLUMN account_email SET NOT NULL,
ADD CONSTRAINT accounts_email_unique UNIQUE (account_email);

8 changes: 4 additions & 4 deletions tests/test_controllers/test_article_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ def test_create_article_atomicity_failure(client, db_session):


def test_edit_article_unauthorized(client, db_session):
author1 = make_account(account_username="Author1", account_role=Role.AUTHOR)
author2 = make_account(account_username="Author2", account_role=Role.AUTHOR)
author1 = make_account(account_username="Author1", account_email="author1@test.com", account_role=Role.AUTHOR)
author2 = make_account(account_username="Author2", account_email="author2@test.com", account_role=Role.AUTHOR)
db_session.add_all([author1, author2])
db_session.commit()

Expand Down Expand Up @@ -158,8 +158,8 @@ def test_edit_article_success_by_author(client, db_session):


def test_admin_cannot_edit_others_article(client, db_session):
author = make_account(account_username="Auteur")
admin = make_account(account_username="Admin", account_role=Role.ADMIN)
author = make_account(account_username="Auteur", account_email="auteur@test.com")
admin = make_account(account_username="Admin", account_email="admin@test.com", account_role=Role.ADMIN)
db_session.add_all([author, admin])
db_session.commit()
article = make_article(author.account_id, article_title="Titre Intouchable")
Expand Down
4 changes: 2 additions & 2 deletions tests/test_controllers/test_comment_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ def test_create_reply_atomicity_failure(client, db_session):


def test_delete_comment_admin_only(client, db_session):
admin = make_account(account_username="Admin", account_role=Role.ADMIN)
user = make_account(account_username="User", account_role=Role.USER)
admin = make_account(account_username="Admin", account_email="admin@test.com", account_role=Role.ADMIN)
user = make_account(account_username="User", account_email="user@test.com", account_role=Role.USER)
db_session.add_all([admin, user])
db_session.commit()
article = make_article(admin.account_id)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_controllers/test_registration_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from app.constants import SessionKey
from tests.factories import make_account


def test_render_registration_page(client):
response = client.get("/registration-page")
assert response.status_code == 200
assert b'action="/create-account"' in response.data


def test_create_account_route_success(client, db_session):
response = client.post(
"/create-account",
data={"username": "new_user", "password": "password", "email": "new_user@test.com"},
follow_redirects=True,
)
assert response.status_code == 200
assert "Account created successfully." in response.data.decode("utf-8")

with client.session_transaction() as session:
assert SessionKey.USER_ID not in session


def test_create_account_route_username_already_exists(client, db_session):
user = make_account(account_username="existing_user", account_password="password", account_email="existing@test.com")
db_session.add(user)
db_session.commit()
response = client.post(
"/create-account",
data={"username": "existing_user", "password": "password", "email": "new@test.com"},
follow_redirects=True,
)
assert response.status_code == 200
assert b'action="/create-account"' in response.data
assert "taken" in response.data.decode("utf-8")


def test_create_account_route_email_already_exists(client, db_session):
user = make_account(account_username="user_one", account_password="password", account_email="taken@test.com")
db_session.add(user)
db_session.commit()
response = client.post(
"/create-account",
data={"username": "user_two", "password": "password", "email": "taken@test.com"},
follow_redirects=True,
)
assert response.status_code == 200
assert b'action="/create-account"' in response.data
assert "email" in response.data.decode("utf-8").lower()
Loading