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
16 changes: 12 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,15 @@ python migrate.py migrate

---

**Last Updated**: November 25, 2025
**Python Version**: 3.12+
**Test Coverage**: 44 tests passing (unit + integration)
**Status**: Active Development - Spec 002 Complete
**Last Updated**: December 4, 2025
**Python Version**: 3.14+
**Test Coverage**: multiple unit & integration tests (see `tests/`) — smoke auth pages test added
**Status**: Active Development - Auth + Migrations integrated

### Recent Updates (Dec 4, 2025)
- Added authentication layer: `User` model, `UserRepository`, `AuthService` (passlib + pbkdf2_sha256 default, bcrypt available), and `auth` blueprint with login/register/change-password/logout flows.
- Authentication uses **email** as the canonical identity key (username field removed).
- Templates updated: `index.html` now exposes a `{% block content %}` to allow auth and other pages to render inside the main layout; login/register templates stored under `templates/user/`.
- Migrations: runner `migrate_db/migrate.py` now prefers the `pyway` CLI (`pyway migrate`) for applying migrations and will fall back to direct SQL application if pyway is unavailable. The project `pyway.yaml` configures the `pyway` metadata table in the SQLite DB.
- Tests: integration smoke test `tests/integration/test_smoke_auth_pages.py` added to verify auth pages render within site layout.

6 changes: 6 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,19 @@
from route.proposal_route import proposal_bp
from route.radio_source_route import radio_source_bp
from route.listen_route import listen_bp
from route.auth_route import auth_bp
from service.auth_service import AuthService

app.register_blueprint(blueprint=main_bp)
app.register_blueprint(blueprint=database_bp)
app.register_blueprint(blueprint=analysis_bp)
app.register_blueprint(blueprint=proposal_bp)
app.register_blueprint(blueprint=radio_source_bp)
app.register_blueprint(blueprint=listen_bp)
app.register_blueprint(blueprint=auth_bp)

# Initialize authentication (LoginManager)
AuthService(app)

# Db is created only by pyway migrations

Expand Down
29 changes: 29 additions & 0 deletions docs/vscode-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
VS Code: Make Explorer (Folders) selected at startup and apply workspace settings to User settings

Follow these steps to apply the recommended settings to your User settings so Explorer (Folders) is preferred on launch.

1) Open VS Code User Settings (JSON)
- Menu: File > Preferences > Settings (or Ctrl+,)
- Click the Open Settings (JSON) icon in the top-right (the {} button)

2) Add the following snippet to your User settings JSON (merge with existing keys):

{
"workbench.startupEditor": "none",
"workbench.editor.enablePreview": false,
"workbench.editor.enablePreviewFromQuickOpen": false,
"explorer.openEditors.visible": 0
}

3) Save the file. These settings will apply to all workspaces on this machine.

4) Make Explorer the active view before closing VS Code
- Press Ctrl+Shift+E to open Explorer, then close VS Code; it usually restores the last active view on startup.

5) If Source Control keeps stealing focus
- Right-click the Source Control icon in the Activity Bar and choose "Hide" (or move it lower).

Notes
- A workspace-specific file was created at .vscode/settings.json in this repository (it may be gitignored). If you prefer workspace-only configuration, copy the snippet into that file instead of User settings.

Generated on: 2025-12-05T07:00:14.483Z
26 changes: 22 additions & 4 deletions migrate_db/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,32 @@ def run_command(cmd: list) -> bool:


def run_migrations():
"""Run all pending database migrations using sqlite3."""
print("🚀 Starting database migrations with sqlite3...")
"""
Run database migrations by first attempting to use the pyway CLI, and falling back to direct SQL application if pyway is unavailable or fails.

This function always tries to apply migrations using the pyway CLI (so pyway's migration tracking table, as configured in `pyway.yaml`, is used).
If the pyway binary cannot be executed or the migration fails, it falls back to applying the SQL files directly (the old behavior).
"""
print("🚀 Starting database migrations (pyway preferred)...")

# Ensure instance directory exists
instance_dir = Path("../instance")
instance_dir.mkdir(exist_ok=True)

# Run migrations
# Try to run pyway CLI
# pyway CLI expects the migration command 'migrate' (not 'apply')
pyway_cmd = [PYWAY_PATH.as_posix(), "--config", "pyway.yaml", "migrate"]
try:
result = run_command(pyway_cmd)
if result:
print("✅ Migrations applied via pyway.")
return
else:
print("⚠️ pyway run failed, falling back to direct SQL application.")
except Exception:
print("⚠️ pyway CLI not available or failed to run; falling back to direct SQL application.")

# Fallback: apply SQL files directly
import sqlite3
db_path = Path("../instance/radio_sources.db")
conn = sqlite3.connect(str(db_path))
Expand All @@ -51,7 +69,7 @@ def run_migrations():
conn.commit()
conn.close()

print("✅ All migrations completed successfully!")
print("✅ All migrations completed successfully (direct SQL).")


def show_migration_status():
Expand Down
13 changes: 13 additions & 0 deletions migrate_db/migrations/V8_0__create_users_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Migration: create users table
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
hash_password VARCHAR(512) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME,
last_modified_at DATETIME
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
31 changes: 31 additions & 0 deletions model/entity/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Literal
from sqlalchemy.sql import func
from database import db


class User(db.Model):
__tablename__ = 'users'

id = db.Column(db.Integer, primary_key=True, autoincrement=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
hash_password = db.Column(db.String(512), nullable=False)
role = db.Column(db.String(20), nullable=False, default='user')
is_active = db.Column(db.Boolean, nullable=False, default=True)

# Timestamps: keep `created_at` and `last_modified_at` per project preference
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
last_modified_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

def get_id(self):
return str(self.id)

@property
def is_authenticated(self) -> bool:
return True

@property
def is_anonymous(self) -> bool:
return False

def __repr__(self):
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
36 changes: 36 additions & 0 deletions model/repository/user_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Optional
from database import db
from model.entity.user import User


class UserRepository:
def __init__(self, session=None):
self.session = session or db.session

def find_by_id(self, user_id: int) -> Optional[User]:
return self.session.query(User).filter(User.id == user_id).first()

def find_by_email(self, email: str) -> Optional[User]:
return self.session.query(User).filter(User.email == email).first()

def create(self, email: str, hash_password: str, role: str = 'user') -> User:
user = User(email=email, hash_password=hash_password, role=role)

self.session.add(user)
self.session.commit()
self.session.refresh(user)
return user

def update_password(self, user: User, new_hash: str) -> User:
user.hash_password = new_hash

self.session.commit()
self.session.refresh(user)
return user

def set_role(self, user: User, role: str) -> User:
user.role = role

self.session.commit()
self.session.refresh(user)
return user
167 changes: 167 additions & 0 deletions pytest-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-8.0.0, pluggy-1.6.0 -- /home/riccardo/Documenti/Programming/Projects/RadioChWeb/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/riccardo/Documenti/Programming/Projects/RadioChWeb
plugins: cov-4.1.0
collecting ... collected 53 items

tests/integration/test_auth_flow.py::test_register_login_logout_flow PASSED [ 1%]
tests/integration/test_smoke_auth_pages.py::test_smoke_auth_pages_render FAILED [ 3%]
tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_complete_save_workflow PASSED [ 5%]
tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_duplicate_stream_url_prevention PASSED [ 7%]
tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_proposal_rejection_workflow PASSED [ 9%]
tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_proposal_update_workflow PASSED [ 11%]
tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_validation_with_missing_required_fields PASSED [ 13%]
tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_insecure_stream_warning PASSED [ 15%]
tests/unit/test_analysis_routes.py::test_delete_analysis_route_removes_row PASSED [ 16%]
tests/unit/test_analysis_routes.py::test_approve_analysis_route_creates_proposal PASSED [ 18%]
tests/unit/test_auth_service.py::test_hash_and_verify_roundtrip PASSED [ 20%]
tests/unit/test_proposal_update.py::test_update_proposal_post FAILED [ 22%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_success PASSED [ 24%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_missing_required_fields PASSED [ 26%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_invalid_url_format PASSED [ 28%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_duplicate_stream_url PASSED [ 30%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_insecure_stream_warning PASSED [ 32%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_nonexistent_proposal PASSED [ 33%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_url_format_valid_urls PASSED [ 35%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_url_format_invalid_urls PASSED [ 37%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_check_duplicate_stream_url_no_duplicate PASSED [ 39%]
tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_check_duplicate_stream_url_duplicate_exists PASSED [ 41%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_save_from_proposal_success PASSED [ 43%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_save_from_proposal_validation_failure PASSED [ 45%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_save_from_proposal_not_found PASSED [ 47%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_reject_proposal_success PASSED [ 49%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_reject_proposal_not_found PASSED [ 50%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_update_proposal_success PASSED [ 52%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_update_proposal_not_found PASSED [ 54%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_get_proposal PASSED [ 56%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_get_all_proposals PASSED [ 58%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_reject_proposal PASSED [ 60%]
tests/unit/test_radio_source_service.py::TestRadioSourceService::test_get_all_radio_sources PASSED [ 62%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_unsupported_protocol_rejection PASSED [ 64%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_https_security_detection PASSED [ 66%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_http_security_warning PASSED [ 67%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_ffmpeg_authoritative_over_curl PASSED [ 69%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_curl_header_extraction PASSED [ 71%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_ffmpeg_output_parsing PASSED [ 73%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_metadata_detection_icecast PASSED [ 75%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_metadata_detection_shoutcast PASSED [ 77%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_prerequisites_check_missing_ffmpeg PASSED [ 79%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_timeout_handling PASSED [ 81%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_extract_metadata_from_ffmpeg_output_basic PASSED [ 83%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_analyze_stream_populates_extracted_metadata_from_ffmpeg PASSED [ 84%]
tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_save_analysis_as_proposal_basic PASSED [ 86%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_find_stream_type_id PASSED [ 88%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_find_stream_type_id_not_found PASSED [ 90%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_stream_type PASSED [ 92%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_stream_type_not_found PASSED [ 94%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_all_stream_types PASSED [ 96%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_predefined_types_map PASSED [ 98%]
tests/unit/test_stream_type_service.py::TestStreamTypeService::test_initialize_predefined_types PASSED [100%]

=================================== FAILURES ===================================
_________________________ test_smoke_auth_pages_render _________________________

test_app = <Flask 'conftest'>

def test_smoke_auth_pages_render(test_app):
> register_blueprints(test_app)

tests/integration/test_smoke_auth_pages.py:23:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/integration/test_smoke_auth_pages.py:20: in register_blueprints
AuthService(app)
service/auth_service.py:17: in __init__
self.init_app(app)
service/auth_service.py:22: in init_app
lm.init_app(app)
.venv/lib64/python3.14/site-packages/flask_login/login_manager.py:137: in init_app
app.after_request(self._update_remember_cookie)
.venv/lib64/python3.14/site-packages/flask/sansio/scaffold.py:43: in wrapper_func
self._check_setup_finished(f_name)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <Flask 'conftest'>, f_name = 'after_request'

def _check_setup_finished(self, f_name: str) -> None:
if self._got_first_request:
> raise AssertionError(
f"The setup method '{f_name}' can no longer be called"
" on the application. It has already handled its first"
" request, any changes will not be applied"
" consistently.\n"
"Make sure all imports, decorators, functions, etc."
" needed to set up the application are done before"
" running it."
)
E AssertionError: The setup method 'after_request' can no longer be called on the application. It has already handled its first request, any changes will not be applied consistently.
E Make sure all imports, decorators, functions, etc. needed to set up the application are done before running it.

.venv/lib64/python3.14/site-packages/flask/sansio/app.py:417: AssertionError
__________________________ test_update_proposal_post ___________________________

test_app = <Flask 'conftest'>
test_db = <sqlalchemy.orm.scoping.scoped_session object at 0x7f014df56120>

def test_update_proposal_post(test_app, test_db):
# Create a proposal in the test DB
proposal = Proposal(
stream_url='https://stream.example.com/test',
name='Old Name',
website_url='https://old.example.com',
stream_type_id=1,
is_secure=False,
country='OldCountry',
description='Old description',
image_url='https://old.example.com/img.png'
)
test_db.add(proposal)
test_db.commit()
test_db.refresh(proposal)

# Prepare updated data
data = {
'name': 'New Name',
'website_url': 'https://new.example.com',
'country': 'Italy',
'description': 'New description',
'image_url': 'https://new.example.com/img.png'
}

# Register blueprint so url_for('proposal.index') resolves during the view
from route.proposal_route import proposal_bp

# Register blueprint so url_for('proposal.index') resolves during the view
from route.proposal_route import proposal_bp
# register only if not present to avoid "register_blueprint after first request" errors
if proposal_bp.name not in test_app.blueprints:
test_app.register_blueprint(proposal_bp)

# Call the view function within a request context
with test_app.test_request_context(f'/proposal/{proposal.id}', method='POST', data=data):
from route.proposal_route import proposal_detail
resp = proposal_detail(proposal.id)

# Expect a redirect response to proposals index
assert resp.status_code == 302

# Reload from DB and assert changes
updated = test_db.query(Proposal).filter(Proposal.id == proposal.id).first()
assert updated is not None
assert updated.name == 'New Name'
assert updated.website_url == 'https://new.example.com'
assert updated.country == 'Italy'
assert updated.description == 'New description'
> assert updated.image_url == 'https://new.example.com/img.png'
E AssertionError: assert 'https://old.example.com/img.png' == 'https://new.example.com/img.png'
E
E - https://new.example.com/img.png
E ? ^^^
E + https://old.example.com/img.png
E ? ^^^

tests/unit/test_proposal_update.py:53: AssertionError
=========================== short test summary info ============================
FAILED tests/integration/test_smoke_auth_pages.py::test_smoke_auth_pages_render
FAILED tests/unit/test_proposal_update.py::test_update_proposal_post - Assert...
========================= 2 failed, 51 passed in 0.63s =========================
7 changes: 6 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
email-validator==2.3.0
pytest==8.0.0
pytest-cov==4.1.0
pydantic==2.12.0
pyway==0.3.32
Flask_WTF==1.2.1
Flask_WTF==1.2.1
passlib[bcrypt]==1.7.4
Flask-Login==0.6.3
email-validator==2.3.0
Loading