diff --git a/app.py b/app.py
index 8d377ed..0b7a834 100644
--- a/app.py
+++ b/app.py
@@ -4,20 +4,24 @@
app = Flask(__name__)
# Configuration
-app.config['SECRET_KEY'] = 'your-secret-key' # Change in production
+app.config["SECRET_KEY"] = "your-secret-key" # Change in production
basedir = os.path.abspath(os.path.dirname(__file__))
-instance_dir = os.path.join(basedir, 'instance')
+instance_dir = os.path.join(basedir, "instance")
os.makedirs(instance_dir, exist_ok=True)
-app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(instance_dir, "radio_sources.db")}'
-app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+app.config["SQLALCHEMY_DATABASE_URI"] = (
+ f'sqlite:///{os.path.join(instance_dir, "radio_sources.db")}'
+)
+app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# Import db from separate module
from database import db
+
db.init_app(app)
# Enable CSRF protection for forms
try:
from flask_wtf import CSRFProtect
+
csrf = CSRFProtect(app)
except Exception:
# If flask-wtf is not installed in the environment, app will still run
@@ -46,5 +50,5 @@
# Db is created only by pyway migrations
-if __name__ == '__main__':
- app.run(debug=True)
\ No newline at end of file
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/database.py b/database.py
index 3ea6804..a628dd1 100644
--- a/database.py
+++ b/database.py
@@ -4,4 +4,4 @@
from flask_sqlalchemy import SQLAlchemy
-db = SQLAlchemy()
\ No newline at end of file
+db = SQLAlchemy()
diff --git a/migrate_db/init_db.py b/migrate_db/init_db.py
index 216b654..f80b780 100644
--- a/migrate_db/init_db.py
+++ b/migrate_db/init_db.py
@@ -9,6 +9,7 @@
DBPATH = Path(__file__).parent.parent / "instance" / "radio_sources.db"
+
def init_database():
"""Initialize a fresh database with all migrations."""
print("šļø Initializing RadioChWeb database...")
diff --git a/migrate_db/migrate.py b/migrate_db/migrate.py
index d718686..d90d227 100644
--- a/migrate_db/migrate.py
+++ b/migrate_db/migrate.py
@@ -10,10 +10,13 @@
PYWAY_PATH = Path(__file__).parent.parent / ".venv" / "bin" / "pyway"
+
def run_command(cmd: list) -> bool:
"""Run a command and return success status."""
try:
- result = subprocess.run(cmd, capture_output=True, text=True, cwd=Path(__file__).parent)
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, cwd=Path(__file__).parent
+ )
if result.returncode != 0:
print(f"ā Command failed: {' '.join(cmd)}")
print(f"Error: {result.stderr}")
@@ -48,21 +51,26 @@ def run_migrations():
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.")
+ 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))
cursor = conn.cursor()
# Get list of migration files
migration_dir = Path("migrations")
- migration_files = sorted([f for f in migration_dir.iterdir() if f.is_file() and f.name.endswith('.sql')])
+ migration_files = sorted(
+ [f for f in migration_dir.iterdir() if f.is_file() and f.name.endswith(".sql")]
+ )
for migration_file in migration_files:
print(f"Applying migration: {migration_file.name}")
- with open(migration_file, 'r') as f:
+ with open(migration_file, "r") as f:
sql = f.read()
cursor.executescript(sql)
@@ -76,11 +84,7 @@ def show_migration_status():
"""Show current migration status."""
print("\nš Migration Status:")
- cmd = [
- PYWAY_PATH.as_posix(),
- "--config", "pyway.yaml",
- "info"
- ]
+ cmd = [PYWAY_PATH.as_posix(), "--config", "pyway.yaml", "info"]
run_command(cmd)
@@ -89,11 +93,7 @@ def validate_migrations():
"""Validate migration checksums."""
print("š Validating migrations...")
- cmd = [
- PYWAY_PATH.as_posix(),
- "--config", "pyway.yaml",
- "validate"
- ]
+ cmd = [PYWAY_PATH.as_posix(), "--config", "pyway.yaml", "validate"]
if run_command(cmd):
print("ā
All migrations are valid!")
@@ -113,4 +113,4 @@ def validate_migrations():
print("Usage: python migrate.py [status|validate]")
sys.exit(1)
else:
- run_migrations()
\ No newline at end of file
+ run_migrations()
diff --git a/migrate_db/migrations/V6_0__add_created_by_fields.sql b/migrate_db/migrations/V6_0__add_created_by_fields.sql
new file mode 100644
index 0000000..468b603
--- /dev/null
+++ b/migrate_db/migrations/V6_0__add_created_by_fields.sql
@@ -0,0 +1,70 @@
+-- V6_0__add_created_by_fields.sql
+-- Rebuild `proposals` and `stream_analysis` to add `created_by` columns
+-- with foreign key constraints referencing `users(id)`.
+
+PRAGMA foreign_keys = OFF;
+BEGIN TRANSACTION;
+
+-- Rebuild proposals table with created_by FK
+CREATE TABLE proposals_new (
+ id INTEGER NOT NULL,
+ stream_url VARCHAR(200) NOT NULL,
+ name VARCHAR(200) NOT NULL,
+ website_url VARCHAR(200),
+ image_url VARCHAR(200),
+ stream_type_id INTEGER NOT NULL,
+ is_secure BOOLEAN NOT NULL DEFAULT 1,
+ country VARCHAR(50),
+ description VARCHAR(200),
+ created_at DATETIME,
+ created_by INTEGER,
+ PRIMARY KEY (id),
+ FOREIGN KEY (stream_type_id) REFERENCES stream_types(id),
+ FOREIGN KEY (created_by) REFERENCES users(id)
+);
+
+-- copy existing data (created_by will be NULL for existing rows)
+INSERT INTO proposals_new (id, stream_url, name, website_url, image_url, stream_type_id, is_secure, country, description, created_at)
+ SELECT id, stream_url, name, website_url, image_url, stream_type_id, is_secure, country, description, created_at
+ FROM proposals;
+
+DROP TABLE proposals;
+ALTER TABLE proposals_new RENAME TO proposals;
+
+CREATE INDEX IF NOT EXISTS idx_proposals_url ON proposals(stream_url);
+CREATE INDEX IF NOT EXISTS idx_proposals_stream_type_id ON proposals(stream_type_id);
+CREATE INDEX IF NOT EXISTS idx_proposals_is_secure ON proposals(is_secure);
+CREATE INDEX IF NOT EXISTS idx_proposals_created_by ON proposals(created_by);
+
+-- Rebuild stream_analysis table with created_by FK
+CREATE TABLE stream_analysis_new (
+ id INTEGER NOT NULL,
+ stream_url VARCHAR(200) NOT NULL,
+ stream_type_id INTEGER,
+ is_valid BOOLEAN NOT NULL,
+ is_secure BOOLEAN NOT NULL,
+ error_code VARCHAR(200),
+ detection_method VARCHAR(200),
+ raw_content_type TEXT NULL,
+ raw_ffmpeg_output TEXT NULL,
+ extracted_metadata TEXT NULL,
+ created_by INTEGER,
+ PRIMARY KEY (id),
+ FOREIGN KEY (stream_type_id) REFERENCES stream_types(id),
+ FOREIGN KEY (created_by) REFERENCES users(id)
+);
+
+INSERT INTO stream_analysis_new (id, stream_url, stream_type_id, is_valid, is_secure, error_code, detection_method, raw_content_type, raw_ffmpeg_output, extracted_metadata)
+ SELECT id, stream_url, stream_type_id, is_valid, is_secure, error_code, detection_method, raw_content_type, raw_ffmpeg_output, extracted_metadata
+ FROM stream_analysis;
+
+DROP TABLE stream_analysis;
+ALTER TABLE stream_analysis_new RENAME TO stream_analysis;
+
+CREATE INDEX IF NOT EXISTS idx_stream_analysis_stream_url ON stream_analysis(stream_url);
+CREATE INDEX IF NOT EXISTS idx_stream_analysis_created_by ON stream_analysis(created_by);
+
+COMMIT;
+PRAGMA foreign_keys = ON;
+
+-- End of migration V6_0
diff --git a/model/dto/stream_analysis.py b/model/dto/stream_analysis.py
index ae487d8..27db0e5 100644
--- a/model/dto/stream_analysis.py
+++ b/model/dto/stream_analysis.py
@@ -25,14 +25,11 @@ class ErrorCode(str, Enum):
class StreamAnalysisRequest(BaseModel):
"""Request DTO for stream analysis (spec 003)."""
+
url: HttpUrl
timeout_seconds: int = 30
- model_config = ConfigDict(
- json_encoders={
- HttpUrl: str
- }
- )
+ model_config = ConfigDict(json_encoders={HttpUrl: str})
class StreamAnalysisResult(BaseModel):
@@ -40,30 +37,35 @@ class StreamAnalysisResult(BaseModel):
Data structure returned by analysis process (persisted for page proposal.html).
This is the main return type from spec 003 analyze-and-classify process.
"""
+
is_valid: bool
is_secure: bool # False for HTTP, true for HTTPS
stream_url: Optional[str] = None # if loaded is the url of proposal stream
stream_type_id: Optional[int] = None # Foreign key to StreamType, null if invalid
- stream_type_display_name: Optional[str] = None # Human-readable name of the stream type
+ stream_type_display_name: Optional[str] = (
+ None # Human-readable name of the stream type
+ )
error_code: Optional[ErrorCode] = None # Null if valid
detection_method: Optional[DetectionMethod] = None # How the stream was detected
raw_content_type: Optional[str] = None # String from curl headers
raw_ffmpeg_output: Optional[str] = None # String from ffmpeg detection
- extracted_metadata: Optional[str] = None # Normalized metadata extracted from ffmpeg stderr
-
- @field_validator('extracted_metadata')
+ extracted_metadata: Optional[str] = (
+ None # Normalized metadata extracted from ffmpeg stderr
+ )
+
+ @field_validator("extracted_metadata")
def _clean_extracted_metadata(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
# remove control chars except newline and tab, trim, and enforce max length
- cleaned = ''.join(ch for ch in v if (ch >= ' ' or ch in '\n\t'))
+ cleaned = "".join(ch for ch in v if (ch >= " " or ch in "\n\t"))
cleaned = cleaned.strip()
if len(cleaned) > 4096:
cleaned = cleaned[:4096]
return cleaned
-
+
def is_success(self) -> bool:
"""Returns True if analysis was successful and stream is valid."""
return self.is_valid and self.error_code is None
-
- model_config = ConfigDict(from_attributes=True)
\ No newline at end of file
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/model/dto/stream_type.py b/model/dto/stream_type.py
index c4447b3..2fed7d1 100644
--- a/model/dto/stream_type.py
+++ b/model/dto/stream_type.py
@@ -7,15 +7,16 @@
class StreamTypeDTO(BaseModel):
"""DTO for StreamType entity."""
+
id: int
protocol: str # HTTP, HTTPS, HLS
- format: str # MP3, AAC, OGG
+ format: str # MP3, AAC, OGG
metadata: str # Icecast, Shoutcast, None (mapped from metadata_type)
display_name: str
-
+
@property
def type_key(self) -> str:
"""Returns the type key in format: PROTOCOL-FORMAT-METADATA"""
return f"{self.protocol}-{self.format}-{self.metadata}"
-
- model_config = ConfigDict(from_attributes=True)
\ No newline at end of file
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/model/dto/validation.py b/model/dto/validation.py
index 8462cf4..e1e92b1 100644
--- a/model/dto/validation.py
+++ b/model/dto/validation.py
@@ -15,6 +15,7 @@ class SecurityStatus(str, Enum):
class ValidationResult(BaseModel):
"""Result of proposal validation."""
+
is_valid: bool
message: str = ""
security_status: Optional[SecurityStatus] = None
@@ -35,6 +36,7 @@ def add_warning(self, warning: str):
class ProposalUpdateRequest(BaseModel):
"""Request DTO for updating proposal details."""
+
name: Optional[str] = None
website_url: Optional[str] = None
country: Optional[str] = None
@@ -45,17 +47,20 @@ class ProposalUpdateRequest(BaseModel):
def has_updates(self) -> bool:
"""Check if any updates are provided."""
- return any([
- self.name is not None,
- self.website_url is not None,
- self.country is not None,
- self.description is not None,
- self.image is not None
- ])
+ return any(
+ [
+ self.name is not None,
+ self.website_url is not None,
+ self.country is not None,
+ self.description is not None,
+ self.image is not None,
+ ]
+ )
class ProposalRequest(BaseModel):
- """Data model for a proposal."""
+ """Data model for a proposal."""
+
id: int
stream_url: str
name: str
@@ -65,7 +70,8 @@ class ProposalRequest(BaseModel):
image_url: Optional[str] = None
stream_type_id: int
is_secure: bool
-
+
model_config = ConfigDict(from_attributes=True)
+
def __repr__(self):
- return f""
\ No newline at end of file
+ return f""
diff --git a/model/entity/proposal.py b/model/entity/proposal.py
index aa9567f..34756ca 100644
--- a/model/entity/proposal.py
+++ b/model/entity/proposal.py
@@ -1,28 +1,32 @@
from sqlalchemy import func
from database import db
+
class Proposal(db.Model):
- __tablename__ = 'proposals'
+ __tablename__ = "proposals"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
stream_url = db.Column(db.String(500), nullable=False, unique=True, index=True)
name = db.Column(db.String(100), nullable=False)
website_url = db.Column(db.String(500))
-
+
# Classification data from analysis
stream_type_id = db.Column(db.Integer, db.ForeignKey("stream_types.id"), nullable=False)
is_secure = db.Column(db.Boolean, nullable=False, default=False)
-
+
# User-editable fields
country = db.Column(db.String(50))
description = db.Column(db.Text)
image_url = db.Column(db.String(500))
-
+ created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
-
- # Relationship
+
+ # Relationship with StreamType
stream_type = db.relationship("StreamType", back_populates="proposals")
-
+
+ # Relationship with User
+ proposal_user = db.relationship("User", back_populates="proposals")
+
def __repr__(self):
- return f""
\ No newline at end of file
+ return f""
diff --git a/model/entity/radio_source.py b/model/entity/radio_source.py
index 790c343..177d745 100644
--- a/model/entity/radio_source.py
+++ b/model/entity/radio_source.py
@@ -1,23 +1,26 @@
from database import db
from sqlalchemy.sql import func
+
class RadioSource(db.Model):
- __tablename__ = 'radio_sources'
+ __tablename__ = "radio_sources"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
stream_url = db.Column(db.String(500), nullable=False, unique=True, index=True)
name = db.Column(db.String(100), nullable=False)
website_url = db.Column(db.String(500))
-
+
# Classification data
- stream_type_id = db.Column(db.Integer, db.ForeignKey("stream_types.id"), nullable=False)
+ stream_type_id = db.Column(
+ db.Integer, db.ForeignKey("stream_types.id"), nullable=False
+ )
is_secure = db.Column(db.Boolean, nullable=False, default=False)
-
+
# User-editable fields
country = db.Column(db.String(50))
description = db.Column(db.Text)
image_url = db.Column(db.String(500))
-
+
# Timestamps
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
@@ -25,4 +28,4 @@ class RadioSource(db.Model):
stream_type = db.relationship("StreamType", back_populates="radio_sources")
def __repr__(self):
- return f""
\ No newline at end of file
+ return f""
diff --git a/model/entity/stream_analysis.py b/model/entity/stream_analysis.py
index f04d1ab..f4836ec 100644
--- a/model/entity/stream_analysis.py
+++ b/model/entity/stream_analysis.py
@@ -1,23 +1,35 @@
from database import db
+
class StreamAnalysis(db.Model):
- __tablename__ = 'stream_analysis'
+ __tablename__ = "stream_analysis"
- id= db.Column(db.Integer, primary_key=True, autoincrement=True)
- stream_url = db.Column(db.String(200), nullable=False)
- stream_type_id = db.Column(db.Integer, db.ForeignKey('stream_types.id'), nullable=True) # Foreign key to StreamType, null if invalid
+ id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+ stream_url = db.Column(db.String(200), nullable=False)
+ stream_type_id = db.Column(
+ db.Integer, db.ForeignKey("stream_types.id"), nullable=True
+ ) # Foreign key to StreamType, null if invalid
is_valid = db.Column(db.Boolean, nullable=False, default=False)
- is_secure = db.Column(db.Boolean, nullable=False, default=False) # False for HTTP, true for HTTPS
+ is_secure = db.Column(
+ db.Boolean, nullable=False, default=False
+ ) # False for HTTP, true for HTTPS
error_code = db.Column(db.String(50), nullable=True) # Null if valid
- detection_method = db.Column(db.String(50), nullable=True) # How the stream was detected
+ detection_method = db.Column(
+ db.String(50), nullable=True
+ ) # How the stream was detected
raw_content_type = db.Column(db.Text, nullable=True) # String from curl headers
- raw_ffmpeg_output = db.Column(db.Text, nullable=True) # String from ffmpeg detection
+ raw_ffmpeg_output = db.Column(
+ db.Text, nullable=True
+ ) # String from ffmpeg detection
extracted_metadata = db.Column(db.Text, nullable=True)
-
+ # Foreign Key to User who created the analysis
+ created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
# Relationship with StreamTypes
stream_type = db.relationship("StreamType", back_populates="stream_analysis")
+
+ # Relationship with User
+ stream_user = db.relationship("User", back_populates="stream_analysis")
def __repr__(self):
return f""
-
diff --git a/model/entity/stream_type.py b/model/entity/stream_type.py
index 126886f..16a9a86 100644
--- a/model/entity/stream_type.py
+++ b/model/entity/stream_type.py
@@ -1,25 +1,26 @@
from database import db
+
class StreamType(db.Model):
- __tablename__ = 'stream_types'
+ __tablename__ = "stream_types"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
protocol = db.Column(db.String(10), nullable=False) # HTTP, HTTPS, HLS
- format = db.Column(db.String(10), nullable=False) # MP3, AAC, OGG
+ format = db.Column(db.String(10), nullable=False) # MP3, AAC, OGG
metadata_type = db.Column(db.String(15), nullable=False) # Icecast, Shoutcast, None
- display_name = db.Column(db.String(100), nullable=False) # Human-readable name
+ display_name = db.Column(db.String(100), nullable=False) # Human-readable name
- # Relationship with RadioSource
+ # Relationship with RadioSource
radio_sources = db.relationship("RadioSource", back_populates="stream_type")
# Relationship with StreamAnalysis
- stream_analysis = db.relationship("StreamAnalysis", back_populates="stream_type")
+ stream_analysis = db.relationship("StreamAnalysis", back_populates="stream_type")
# Relationship with Proposals
- proposals = db.relationship("Proposal", back_populates="stream_type")
-
+ proposals = db.relationship("Proposal", back_populates="stream_type")
+
def __repr__(self):
return f""
@property
def type_key(self):
"""Returns the type key in format: PROTOCOL-FORMAT-METADATA"""
- return f"{self.protocol}-{self.format}-{self.metadata_type}"
\ No newline at end of file
+ return f"{self.protocol}-{self.format}-{self.metadata_type}"
diff --git a/model/entity/user.py b/model/entity/user.py
index 4ed54e2..0036e56 100644
--- a/model/entity/user.py
+++ b/model/entity/user.py
@@ -4,17 +4,24 @@
class User(db.Model):
- __tablename__ = 'users'
+ __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')
+ 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())
+ last_modified_at = db.Column(
+ db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ # Relationship with StreamAnalysis
+ stream_analysis = db.relationship("StreamAnalysis", back_populates="stream_user")
+ # Relationship with Proposal
+ proposals = db.relationship("Proposal", back_populates="proposal_user")
def get_id(self):
return str(self.id)
@@ -27,5 +34,9 @@ def is_authenticated(self) -> bool:
def is_anonymous(self) -> bool:
return False
+ @property
+ def is_admin(self) -> bool:
+ return (self.role or "").lower() == "admin"
+
def __repr__(self):
return f""
diff --git a/model/repository/__init__.py b/model/repository/__init__.py
index c21b104..b816725 100644
--- a/model/repository/__init__.py
+++ b/model/repository/__init__.py
@@ -6,4 +6,4 @@
from .radio_source_repository import RadioSourceRepository
from .proposal_repository import ProposalRepository
-__all__ = ["StreamTypeRepository", "RadioSourceRepository", "ProposalRepository"]
\ No newline at end of file
+__all__ = ["StreamTypeRepository", "RadioSourceRepository", "ProposalRepository"]
diff --git a/model/repository/proposal_repository.py b/model/repository/proposal_repository.py
index 4c77a04..6d8e2a9 100644
--- a/model/repository/proposal_repository.py
+++ b/model/repository/proposal_repository.py
@@ -9,22 +9,22 @@
class ProposalRepository:
"""Repository for Proposal data access operations."""
-
+
def __init__(self, db_session: Session):
self.db = db_session
-
+
def find_by_id(self, proposal_id: int) -> Optional[Proposal]:
"""Get Proposal by ID."""
return self.db.query(Proposal).filter(Proposal.id == proposal_id).first()
-
+
def find_by_url(self, url: str) -> Optional[Proposal]:
"""Get Proposal by URL."""
return self.db.query(Proposal).filter(Proposal.url == url).first()
-
+
def find_all(self) -> List[Proposal]:
"""Get all Proposals."""
return self.db.query(Proposal).all()
-
+
def save(self, proposal: Proposal) -> Proposal:
"""Save (create or update) a Proposal."""
if proposal.id is None:
@@ -32,7 +32,7 @@ def save(self, proposal: Proposal) -> Proposal:
self.db.commit()
self.db.refresh(proposal)
return proposal
-
+
def delete(self, proposal_id: int) -> bool:
"""Delete a Proposal by ID."""
proposal = self.find_by_id(proposal_id)
@@ -41,15 +41,28 @@ def delete(self, proposal_id: int) -> bool:
self.db.commit()
return True
return False
-
+
def count(self) -> int:
"""Count total Proposals."""
return self.db.query(Proposal).count()
-
+
def exists_by_stream_url(self, stream_url: str) -> bool:
"""Check if a Proposal with the given stream URL already exists."""
- return self.db.query(Proposal).filter(Proposal.stream_url == stream_url).first() is not None
-
+ return (
+ self.db.query(Proposal).filter(Proposal.stream_url == stream_url).first()
+ is not None
+ )
+
def get_all_proposals(self) -> List[Proposal]:
"""Retrieve all proposals from the database."""
- return self.db.query(Proposal).all()
\ No newline at end of file
+ return self.db.query(Proposal).all()
+
+ def find_by_creator(self, user_id: int) -> List[Proposal]:
+ """Get all Proposals created by a specific user."""
+ return (
+ self.db.query(Proposal).filter(Proposal.created_by == user_id).all()
+ )
+
+ # backward-compatible alias
+ def find_by_created_by(self, user_id: int) -> List[Proposal]:
+ return self.find_by_creator(user_id)
diff --git a/model/repository/radio_source_repository.py b/model/repository/radio_source_repository.py
index ac885f2..b2cad5e 100644
--- a/model/repository/radio_source_repository.py
+++ b/model/repository/radio_source_repository.py
@@ -9,30 +9,38 @@
class RadioSourceRepository:
"""Repository for RadioSource data access operations."""
-
+
def __init__(self, db_session: Session):
self.db = db_session
-
+
def find_by_id(self, source_id: int) -> Optional[RadioSource]:
"""Get RadioSource by ID."""
return self.db.query(RadioSource).filter(RadioSource.id == source_id).first()
-
+
def find_by_url(self, url: str) -> Optional[RadioSource]:
"""Get RadioSource by URL (for duplicate checking)."""
return self.db.query(RadioSource).filter(RadioSource.stream_url == url).first()
-
+
def find_all(self) -> List[RadioSource]:
"""Get all RadioSources."""
return self.db.query(RadioSource).all()
-
+
def find_by_stream_type(self, stream_type_id: int) -> List[RadioSource]:
"""Get RadioSources by stream type."""
- return self.db.query(RadioSource).filter(RadioSource.stream_type_id == stream_type_id).all()
-
+ return (
+ self.db.query(RadioSource)
+ .filter(RadioSource.stream_type_id == stream_type_id)
+ .all()
+ )
+
def search_by_name(self, name_query: str) -> List[RadioSource]:
"""Search RadioSources by name."""
- return self.db.query(RadioSource).filter(RadioSource.name.ilike(f'%{name_query}%')).all()
-
+ return (
+ self.db.query(RadioSource)
+ .filter(RadioSource.name.ilike(f"%{name_query}%"))
+ .all()
+ )
+
def save(self, radio_source: RadioSource) -> RadioSource:
"""Save (create or update) a RadioSource."""
if radio_source.id is None:
@@ -40,7 +48,7 @@ def save(self, radio_source: RadioSource) -> RadioSource:
self.db.commit()
self.db.refresh(radio_source)
return radio_source
-
+
def delete(self, source_id: int) -> bool:
"""Delete a RadioSource by ID."""
radio_source = self.find_by_id(source_id)
@@ -49,7 +57,7 @@ def delete(self, source_id: int) -> bool:
self.db.commit()
return True
return False
-
+
def count(self) -> int:
"""Count total RadioSources."""
- return self.db.query(RadioSource).count()
\ No newline at end of file
+ return self.db.query(RadioSource).count()
diff --git a/model/repository/stream_analysis_repository.py b/model/repository/stream_analysis_repository.py
index a419c14..b3b3208 100644
--- a/model/repository/stream_analysis_repository.py
+++ b/model/repository/stream_analysis_repository.py
@@ -2,61 +2,64 @@
StreamAnalysysRepository - Data access layer for StreamAnalysys entity.
"""
-from typing import Optional, List, Dict
+from typing import Optional, List
from sqlalchemy.orm import Session
from model.entity.stream_analysis import StreamAnalysis
class StreamAnalysisRepository:
"""Repository for StreamAnalysis data access operations."""
-
+
def __init__(self, db_session: Session):
self.db = db_session
-
+
def find_by_id(self, id: int) -> Optional[StreamAnalysis]:
"""Get StreamAnalysis by ID."""
return self.db.query(StreamAnalysis).filter(StreamAnalysis.id == id).first()
-
+
def find_all(self) -> List[StreamAnalysis]:
"""Get all StreamAnalysises."""
return self.db.query(StreamAnalysis).all()
-
+
def count(self) -> int:
"""Count total StreamAnalysises."""
return self.db.query(StreamAnalysis).count()
-
+
def find_by_url(self, stream_url: str) -> Optional[StreamAnalysis]:
"""
Find StreamAnalysis ID by stream url
-
+
Returns:
StreamAnalysis if found, None otherwise
"""
- stream_analysis = self.db.query(StreamAnalysis).filter(
- StreamAnalysis.stream_url == stream_url
- ).first()
-
+ stream_analysis = (
+ self.db.query(StreamAnalysis)
+ .filter(StreamAnalysis.stream_url == stream_url)
+ .first()
+ )
+
return stream_analysis if stream_analysis else None
-
def save(self, new_analysis: StreamAnalysis) -> StreamAnalysis:
"""
Create a StreamAnalysis if it doesn't already exist.
Used for initializing predefined types.
"""
- existing = self.db.query(StreamAnalysis).filter(
- StreamAnalysis.stream_url == new_analysis.stream_url
- ).first()
-
+ existing = (
+ self.db.query(StreamAnalysis)
+ .filter(StreamAnalysis.stream_url == new_analysis.stream_url)
+ .first()
+ )
+
if existing:
return existing
-
+
self.db.add(new_analysis)
self.db.commit()
self.db.refresh(new_analysis)
-
+
return new_analysis
-
+
def delete(self, id: int) -> bool:
"""Delete a StreamAnalysis by ID."""
existing = self.find_by_id(id)
@@ -64,4 +67,14 @@ def delete(self, id: int) -> bool:
self.db.delete(existing)
self.db.commit()
return True
- return False
\ No newline at end of file
+ return False
+
+ def find_by_creator(self, user_id: int) -> List[StreamAnalysis]:
+ """Get all StreamAnalysis rows created by a specific user."""
+ return (
+ self.db.query(StreamAnalysis).filter(StreamAnalysis.created_by == user_id).all()
+ )
+
+ # backward-compatible alias
+ def find_by_created_by(self, user_id: int) -> List[StreamAnalysis]:
+ return self.find_by_creator(user_id)
diff --git a/model/repository/stream_type_repository.py b/model/repository/stream_type_repository.py
index 5822c9e..c220146 100644
--- a/model/repository/stream_type_repository.py
+++ b/model/repository/stream_type_repository.py
@@ -9,68 +9,80 @@
class StreamTypeRepository:
"""Repository for StreamType data access operations."""
-
+
def __init__(self, db_session: Session):
self.db = db_session
-
+
def find_by_id(self, stream_type_id: int) -> Optional[StreamType]:
"""Get StreamType by ID."""
return self.db.query(StreamType).filter(StreamType.id == stream_type_id).first()
-
+
def find_all(self) -> List[StreamType]:
"""Get all StreamTypes."""
return self.db.query(StreamType).all()
-
+
def count(self) -> int:
"""Count total StreamTypes."""
return self.db.query(StreamType).count()
-
- def find_by_combination(self, protocol: str, format_type: str, metadata: str) -> Optional[int]:
+
+ def find_by_combination(
+ self, protocol: str, format_type: str, metadata: str
+ ) -> Optional[int]:
"""
Find StreamType ID by protocol, format, and metadata combination.
-
+
Returns:
StreamType ID if found, None otherwise
"""
- stream_type = self.db.query(StreamType).filter(
- StreamType.protocol == protocol,
- StreamType.format == format_type,
- StreamType.metadata_type == metadata
- ).first()
-
+ stream_type = (
+ self.db.query(StreamType)
+ .filter(
+ StreamType.protocol == protocol,
+ StreamType.format == format_type,
+ StreamType.metadata_type == metadata,
+ )
+ .first()
+ )
+
return stream_type.id if stream_type else None
-
- def create_if_not_exists(self, protocol: str, format_type: str, metadata: str, display_name: str) -> StreamType:
+
+ def create_if_not_exists(
+ self, protocol: str, format_type: str, metadata: str, display_name: str
+ ) -> StreamType:
"""
Create a StreamType if it doesn't already exist.
Used for initializing predefined types.
"""
- existing = self.db.query(StreamType).filter(
- StreamType.protocol == protocol,
- StreamType.format == format_type,
- StreamType.metadata_type == metadata
- ).first()
-
+ existing = (
+ self.db.query(StreamType)
+ .filter(
+ StreamType.protocol == protocol,
+ StreamType.format == format_type,
+ StreamType.metadata_type == metadata,
+ )
+ .first()
+ )
+
if existing:
return existing
-
+
new_type = StreamType(
protocol=protocol,
format=format_type,
metadata_type=metadata,
- display_name=display_name
+ display_name=display_name,
)
-
+
self.db.add(new_type)
self.db.commit()
self.db.refresh(new_type)
-
+
return new_type
-
+
def get_type_key_to_id_map(self) -> Dict[str, int]:
"""
Get a dictionary mapping type keys (PROTOCOL-FORMAT-METADATA) to IDs.
Useful for quick lookups during stream analysis.
"""
stream_types = self.get_all()
- return {st.type_key: st.id for st in stream_types}
\ No newline at end of file
+ return {st.type_key: st.id for st in stream_types}
diff --git a/model/repository/user_repository.py b/model/repository/user_repository.py
index 4279ad3..2deac01 100644
--- a/model/repository/user_repository.py
+++ b/model/repository/user_repository.py
@@ -12,8 +12,8 @@ def find_by_id(self, user_id: int) -> Optional[User]:
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:
+
+ 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)
@@ -30,7 +30,7 @@ def update_password(self, user: User, new_hash: str) -> User:
def set_role(self, user: User, role: str) -> User:
user.role = role
-
+
self.session.commit()
self.session.refresh(user)
return user
diff --git a/pytest-output.txt b/pytest-output.txt
deleted file mode 100644
index 71cd260..0000000
--- a/pytest-output.txt
+++ /dev/null
@@ -1,167 +0,0 @@
-============================= 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 =
-
- 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 = , 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 =
-test_db =
-
- 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 =========================
diff --git a/route/analysis_route.py b/route/analysis_route.py
index 078ab5f..3da95fd 100644
--- a/route/analysis_route.py
+++ b/route/analysis_route.py
@@ -4,88 +4,109 @@
"""
from typing import List
-from flask import Blueprint, request, jsonify, render_template, redirect, url_for, flash
+from flask import Blueprint, request, render_template, redirect, url_for, flash
+from flask_login import login_required, current_user
from model.entity.stream_analysis import StreamAnalysis
from model.dto.stream_analysis import StreamAnalysisResult
from model.repository.stream_analysis_repository import StreamAnalysisRepository
from model.repository.proposal_repository import ProposalRepository
from model.repository.radio_source_repository import RadioSourceRepository
-from model.entity.proposal import Proposal
from model.repository.stream_type_repository import StreamTypeRepository
from service.stream_analysis_service import StreamAnalysisService
-from service.stream_type_service import StreamTypeService
-analysis_bp = Blueprint('analysis', __name__, url_prefix='/analysis')
+analysis_bp = Blueprint("analysis", __name__, url_prefix="/analysis")
+
# Repository and service initialization functions
def get_analysis_repo() -> StreamAnalysisRepository:
from database import db
+
return StreamAnalysisRepository(db.session)
+
def get_proposal_repo():
from database import db
+
return ProposalRepository(db.session)
+
def get_radio_source_repo():
from database import db
+
return RadioSourceRepository(db.session)
+
def get_validation_service():
proposal_repo = get_proposal_repo()
radio_source_repo = get_radio_source_repo()
from service.proposal_validation_service import ProposalValidationService
+
return ProposalValidationService(proposal_repo, radio_source_repo)
+
def get_radio_source_service():
proposal_repo = get_proposal_repo()
radio_source_repo = get_radio_source_repo()
validation_service = get_validation_service()
from service.radio_source_service import RadioSourceService
+
return RadioSourceService(proposal_repo, radio_source_repo, validation_service)
+
def get_stream_type_repo() -> StreamTypeRepository:
from database import db
from model.repository.stream_type_repository import StreamTypeRepository
+
return StreamTypeRepository(db.session)
+
def get_stream_analysis_service() -> StreamAnalysisService:
stream_type_service = get_stream_type_service()
from service.stream_analysis_service import StreamAnalysisService
- return StreamAnalysisService(stream_type_service, get_proposal_repo(), get_analysis_repo())
+
+ return StreamAnalysisService(
+ stream_type_service, get_proposal_repo(), get_analysis_repo()
+ )
def get_stream_type_service():
stream_type_repo = get_stream_type_repo()
from service.stream_type_service import StreamTypeService
+
return StreamTypeService(stream_type_repo)
-@analysis_bp.route('/', methods=['GET'])
+@analysis_bp.route("/", methods=["GET"])
def index():
"""Display the analysis page with all stream analyses."""
analysis_repo = get_analysis_repo()
# Get all analyses for display (pass entity objects so templates can access id)
streams_from_db: List[StreamAnalysis] = analysis_repo.find_all()
- return render_template('analysis.html', streams=streams_from_db)
+ return render_template("analysis.html", streams=streams_from_db)
-@analysis_bp.route('/analyze', methods=['POST'])
+# only a registered user can analyze a stream and save as proposal
+@login_required
+@analysis_bp.route("/analyze", methods=["POST"])
def analyze_url():
"""Analyze a stream URL and show results."""
- url = request.form.get('url')
-
+ url = request.form.get("url")
+
if not url:
- flash('URL is required', 'error')
- return redirect(url_for('analysis.index'))
-
+ flash("URL is required", "error")
+ return redirect(url_for("analysis.index"))
+
try:
analysis_service: StreamAnalysisService = get_stream_analysis_service()
result: StreamAnalysisResult = analysis_service.analyze_stream(url)
# Show the result in a simple format
- flash(f'Analysis result: {result.stream_type_display_name if result.stream_type_display_name else "Unknown"}', 'info')
+ flash(
+ f'Analysis result: {result.stream_type_display_name if result.stream_type_display_name else "Unknown"}',
+ "info",
+ )
# Save detail in repo
analysis_repo = get_analysis_repo()
@@ -94,48 +115,56 @@ def analyze_url():
is_valid=result.is_valid,
is_secure=result.is_secure,
error_code=result.error_code.name if result.error_code else None,
- stream_type_id=result.stream_type_id,
- detection_method=result.detection_method.name if result.detection_method else None,
+ stream_type_id=result.stream_type_id,
+ detection_method=(
+ result.detection_method.name if result.detection_method else None
+ ),
raw_content_type=result.raw_content_type,
raw_ffmpeg_output=result.raw_ffmpeg_output,
- extracted_metadata=result.extracted_metadata
+ extracted_metadata=result.extracted_metadata,
+ created_by=current_user.id,
)
- analysis_repo.save(analysis_entity)
-
+ analysis_repo.save(analysis_entity)
+
except Exception as e:
- flash(f'Analysis failed: {str(e)}', 'error')
-
- return redirect(url_for('analysis.index'))
+ flash(f"Analysis failed: {str(e)}", "error")
+
+ return redirect(url_for("analysis.index"))
-@analysis_bp.route('/approve/', methods=['POST'])
+@login_required
+@analysis_bp.route("/approve/", methods=["POST"])
def approve_analysis(id: int):
"""Approve an analysis and creates a proposal."""
-
+
try:
analysis_service: StreamAnalysisService = get_stream_analysis_service()
- success = analysis_service.save_analysis_as_proposal(id)
+ success = analysis_service.save_analysis_as_proposal(id, created_by=current_user.id)
if success:
- flash('ProposalAnalysis approved and added as proposal for radio source!', 'success')
+ flash(
+ "ProposalAnalysis approved and added as proposal for radio source!",
+ "success",
+ )
else:
- flash('Failed to approve stream analysis', 'error')
+ flash("Failed to approve stream analysis", "error")
except Exception as e:
- flash(f'Error approving stream analysis: {str(e)}', 'error')
+ flash(f"Error approving stream analysis: {str(e)}", "error")
- return redirect(url_for('proposal.index'))
+ return redirect(url_for("proposal.index"))
-@analysis_bp.route('/delete/', methods=['POST'])
+@login_required
+@analysis_bp.route("/delete/", methods=["POST"])
def delete_analysis(id: int):
"""Delete an analysis."""
analysis_service: StreamAnalysisService = get_stream_analysis_service()
-
+
try:
success = analysis_service.delete_analysis(id)
if success:
- flash('Stream analysis deleted successfully!', 'success')
+ flash("Stream analysis deleted successfully!", "success")
else:
- flash('Failed to delete stream analysis', 'error')
+ flash("Failed to delete stream analysis", "error")
except Exception as e:
- flash(f'Error deleting stream analysis: {str(e)}', 'error')
- return redirect(url_for('analysis.index'))
\ No newline at end of file
+ flash(f"Error deleting stream analysis: {str(e)}", "error")
+ return redirect(url_for("analysis.index"))
diff --git a/route/auth_route.py b/route/auth_route.py
index 6f7d1e3..65bf090 100644
--- a/route/auth_route.py
+++ b/route/auth_route.py
@@ -11,29 +11,34 @@
auth_service = AuthService()
-auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
+auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
class RegisterForm(FlaskForm):
- email = StringField('Email', validators=[DataRequired(), Email()])
- password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
- confirm = PasswordField('Confirm', validators=[DataRequired(), EqualTo('password')])
- submit = SubmitField('Register')
+ email = StringField("Email", validators=[DataRequired(), Email()])
+ password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
+ confirm = PasswordField("Confirm", validators=[DataRequired(), EqualTo("password")])
+ submit = SubmitField("Register")
+
class ChangePasswordForm(FlaskForm):
- old_password = PasswordField('Current password', validators=[DataRequired()])
- new_password = PasswordField('New password', validators=[DataRequired(), Length(min=8)])
- confirm = PasswordField('Confirm', validators=[DataRequired(), EqualTo('new_password')])
- submit = SubmitField('Change Password')
+ old_password = PasswordField("Current password", validators=[DataRequired()])
+ new_password = PasswordField(
+ "New password", validators=[DataRequired(), Length(min=8)]
+ )
+ confirm = PasswordField(
+ "Confirm", validators=[DataRequired(), EqualTo("new_password")]
+ )
+ submit = SubmitField("Change Password")
+
class LoginForm(FlaskForm):
- email = StringField('Email', validators=[DataRequired(), Email()])
- password = PasswordField('Password', validators=[DataRequired()])
- submit = SubmitField('Login')
+ email = StringField("Email", validators=[DataRequired(), Email()])
+ password = PasswordField("Password", validators=[DataRequired()])
+ submit = SubmitField("Login")
-user_repo = UserRepository()
-@auth_bp.route('/change_password', methods=['GET', 'POST'])
+@auth_bp.route("/change_password", methods=["GET", "POST"])
@login_required
def change_password():
form = ChangePasswordForm()
@@ -47,65 +52,62 @@ def change_password():
else:
verified = bool(res)
if not verified:
- flash('Current password incorrect', 'error')
- return render_template('user/change_password.html', form=form), 400
+ flash("Current password incorrect", "error")
+ return render_template("user/change_password.html", form=form), 400
auth_service.change_password(user, new)
- flash('Password changed successfully', 'success')
- return redirect(url_for('main.index'))
- return render_template('user/change_password.html', form=form)
-
-user_repo = UserRepository()
+ flash("Password changed successfully", "success")
+ return redirect(url_for("main.index"))
+ return render_template("user/change_password.html", form=form)
-
-
-@auth_bp.route('/login', methods=['GET', 'POST'])
+@auth_bp.route("/login", methods=["GET", "POST"])
def login():
form = LoginForm()
if form.validate_on_submit():
- found_user: User = auth_service.user_repo.find_by_email(form.email.data) # adapt to your repo/service call
-
- if found_user and auth_service.verify_password(form.password.data, found_user.hash_password):
- login_user(found_user)
- flash('Signed in successfully', 'success')
- next_page = request.args.get('next') or url_for('main.index')
- return redirect(next_page)
+ found_user: User = auth_service.user_repo.find_by_email(
+ form.email.data
+ ) # adapt to your repo/service call
+
+
verified = False
if found_user:
- res = auth_service.verify_password(form.password.data, found_user.hash_password)
+ res = auth_service.verify_password(
+ form.password.data, found_user.hash_password
+ )
if isinstance(res, tuple):
verified = bool(res[0])
else:
verified = bool(res)
-
+
if not verified:
- flash('Invalid email or password', 'error')
- return render_template('user/login.html', form=form)
+ flash("Invalid email or password", "error")
+ return render_template("user/login.html", form=form)
else:
login_user(found_user)
- flash('Signed in successfully', 'success')
- next_page = request.args.get('next') or url_for('main.index')
+ flash("Signed in successfully", "success")
+ next_page = request.args.get("next") or url_for("main.index")
return redirect(next_page)
-
- return render_template('user/login.html', form=form)
+
+ return render_template("user/login.html", form=form)
-@auth_bp.route('/logout')
+@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
- return redirect(url_for('main.index'))
+ return redirect(url_for("main.index"))
-@auth_bp.route('/register', methods=['GET', 'POST'])
+@auth_bp.route("/register", methods=["GET", "POST"])
def register():
form = RegisterForm()
if form.validate_on_submit():
try:
- auth_service.register_user(email=form.email.data, password=form.password.data)
- flash('Registration successful. Please login.', 'success')
- return redirect(url_for('auth.login'))
+ auth_service.register_user(
+ email=form.email.data, password=form.password.data
+ )
+ flash("Registration successful. Please login.", "success")
+ return redirect(url_for("auth.login"))
except ValueError as e:
- flash(str(e), 'error')
- return render_template('user/register.html', form=form)
-
+ flash(str(e), "error")
+ return render_template("user/register.html", form=form)
diff --git a/route/database_route.py b/route/database_route.py
index 527ce50..7526325 100644
--- a/route/database_route.py
+++ b/route/database_route.py
@@ -9,31 +9,36 @@
from model.repository.proposal_repository import ProposalRepository
from database import db
-database_bp = Blueprint('database', __name__, url_prefix='/database')
+database_bp = Blueprint("database", __name__, url_prefix="/database")
+
# Repository initialization functions
def get_radio_source_repo():
from database import db
+
return RadioSourceRepository(db.session)
+
def get_stream_type_repo():
from database import db
+
return StreamTypeRepository(db.session)
+
def get_proposal_repo():
from database import db
- return ProposalRepository(db.session)
+ return ProposalRepository(db.session)
-@database_bp.route('/sources')
+@database_bp.route("/sources")
def list_sources():
"""List all radio sources with filtering options."""
radio_source_repo = get_radio_source_repo()
stream_type_repo = get_stream_type_repo()
-
- stream_type_filter = request.args.get('stream_type')
- search_query = request.args.get('search')
+
+ stream_type_filter = request.args.get("stream_type")
+ search_query = request.args.get("search")
if stream_type_filter:
sources = radio_source_repo.find_by_stream_type(int(stream_type_filter))
@@ -44,52 +49,57 @@ def list_sources():
stream_types = stream_type_repo.find_all()
- return render_template('sources.html',
- sources=sources,
- stream_types=stream_types,
- current_filter=stream_type_filter,
- search_query=search_query)
+ return render_template(
+ "sources.html",
+ sources=sources,
+ stream_types=stream_types,
+ current_filter=stream_type_filter,
+ search_query=search_query,
+ )
-@database_bp.route('/proposals')
+@database_bp.route("/proposals")
def list_proposals():
"""List all pending proposals."""
proposal_repo = get_proposal_repo()
proposals = proposal_repo.find_all()
- return render_template('proposals.html', proposals=proposals)
+ return render_template("proposals.html", proposals=proposals)
-@database_bp.route('/database')
+@database_bp.route("/database")
def index():
"""Database management page with overview of all entities."""
radio_source_repo = get_radio_source_repo()
stream_type_repo = get_stream_type_repo()
proposal_repo = get_proposal_repo()
-
+
stream_types = stream_type_repo.find_all()
proposals = proposal_repo.find_all()
radio_sources = radio_source_repo.find_all()
-
- return render_template('database.html',
- stream_types=stream_types,
- proposals=proposals,
- radio_sources=radio_sources)
+ return render_template(
+ "database.html",
+ stream_types=stream_types,
+ proposals=proposals,
+ radio_sources=radio_sources,
+ )
-@database_bp.route('/api/stats')
+
+@database_bp.route("/api/stats")
def get_stats():
"""API endpoint to get database statistics."""
radio_source_repo = get_radio_source_repo()
proposal_repo = get_proposal_repo()
stream_type_repo = get_stream_type_repo()
-
+
source_count = radio_source_repo.count()
proposal_count = proposal_repo.count()
stream_type_count = stream_type_repo.count()
- return jsonify({
- 'total_sources': source_count,
- 'total_proposals': proposal_count,
- 'total_stream_types': stream_type_count
- })
-
+ return jsonify(
+ {
+ "total_sources": source_count,
+ "total_proposals": proposal_count,
+ "total_stream_types": stream_type_count,
+ }
+ )
diff --git a/route/listen_route.py b/route/listen_route.py
index a41022f..54e3ee6 100644
--- a/route/listen_route.py
+++ b/route/listen_route.py
@@ -4,6 +4,7 @@
listen_bp = Blueprint("listen", __name__, url_prefix="/listen")
+
@listen_bp.route("/")
def player(source_id: int):
repo = RadioSourceRepository(db.session)
@@ -11,4 +12,4 @@ def player(source_id: int):
if source is None:
abort(404)
# Ensure stream_url exists and is a string; further validation/sanitization can be added here
- return render_template("listen_player.html", source=source)
\ No newline at end of file
+ return render_template("listen_player.html", source=source)
diff --git a/route/main_route.py b/route/main_route.py
index 3224d09..4f4fc57 100644
--- a/route/main_route.py
+++ b/route/main_route.py
@@ -1,19 +1,20 @@
-
from flask import Blueprint, render_template
from model.repository.radio_source_repository import RadioSourceRepository
-main_bp = Blueprint('main', __name__, url_prefix='/')
+main_bp = Blueprint("main", __name__, url_prefix="/")
+
# Repository initialization functions
def get_radio_source_repo():
from database import db
+
return RadioSourceRepository(db.session)
-@main_bp.route('/')
+@main_bp.route("/")
def index():
"""Main index page - list all radio sources."""
radio_source_repo = get_radio_source_repo()
sources = radio_source_repo.find_all()
- return render_template('index.html', sources=sources)
\ No newline at end of file
+ return render_template("index.html", sources=sources)
diff --git a/route/proposal_route.py b/route/proposal_route.py
index 0a30dd0..80e5594 100644
--- a/route/proposal_route.py
+++ b/route/proposal_route.py
@@ -5,6 +5,7 @@
from typing import List
from flask import Blueprint, request, render_template, redirect, url_for, flash
+from flask_login import current_user, login_required
from model.entity.radio_source import RadioSource
from model.repository.proposal_repository import ProposalRepository
@@ -12,73 +13,82 @@
from model.entity.proposal import Proposal
from model.dto.validation import ProposalUpdateRequest
from service.proposal_service import ProposalService
+from service.authorization import admin_required
-proposal_bp = Blueprint('proposal', __name__, url_prefix='/proposal')
+proposal_bp = Blueprint("proposal", __name__, url_prefix="/proposal")
def get_proposal_repo():
from database import db
+
return ProposalRepository(db.session)
+
def get_radio_source_repo():
from database import db
+
return RadioSourceRepository(db.session)
+
def get_validation_service():
proposal_repo = get_proposal_repo()
radio_source_repo = get_radio_source_repo()
from service.proposal_validation_service import ProposalValidationService
+
return ProposalValidationService(proposal_repo, radio_source_repo)
+
def get_radio_source_service():
proposal_repo = get_proposal_repo()
radio_source_repo = get_radio_source_repo()
validation_service = get_validation_service()
from service.radio_source_service import RadioSourceService
+
return RadioSourceService(proposal_repo, radio_source_repo, validation_service)
+
def get_stream_type_service():
stream_type_repo = get_stream_type_repo()
from service.stream_type_service import StreamTypeService
+
return StreamTypeService(stream_type_repo)
def get_proposal_service():
proposal_repo = get_proposal_repo()
from service.proposal_service import ProposalService
+
return ProposalService(proposal_repo)
-@proposal_bp.route('/', methods=['GET'])
+@proposal_bp.route("/", methods=["GET"])
def index():
"""Display the proposals page with all proposals"""
proposal_repo: ProposalRepository = get_proposal_repo()
# Get all proposals for display (pass entity objects so templates can access id)
proposals_from_db: List[Proposal] = proposal_repo.find_all()
- return render_template('proposals.html', proposals=proposals_from_db)
+ return render_template("proposals.html", proposals=proposals_from_db)
-@proposal_bp.route('/propose', methods=['GET', 'POST'])
+# only a registered use can propose a new radio source
+@login_required
+@proposal_bp.route("/propose", methods=["GET", "POST"])
def propose():
"""Handle proposal submission form."""
proposal_repo = get_proposal_repo()
validation_service = get_validation_service()
- if request.method == 'POST':
+ if request.method == "POST":
# Extract form data
- name = request.form.get('name')
- url = request.form.get('url')
- description = request.form.get('description', '')
- user_name = request.form.get('user_name', 'Anonymous')
+ name = request.form.get("name")
+ url = request.form.get("url")
+ description = request.form.get("description", "")
# Create proposal
proposal = Proposal(
- name=name,
- url=url,
- description=description,
- user_name=user_name
+ name=name, url=url, description=description, proposal_user=current_user
)
# Validate and save
@@ -87,69 +97,74 @@ def propose():
if result.is_valid:
# Save proposal
proposal_repo.save(proposal)
- flash('Proposal submitted successfully!', 'success')
- return redirect(url_for('proposal.proposal_detail', proposal_id=proposal.id))
+ flash("Proposal submitted successfully!", "success")
+ return redirect(
+ url_for("proposal.proposal_detail", proposal_id=proposal.id)
+ )
else:
- flash(f'Validation failed: {result.message}', 'error')
+ flash(f"Validation failed: {result.message}", "error")
except Exception as e:
- flash(f'Error submitting proposal: {str(e)}', 'error')
-
- # After proposing or when visiting this endpoint, show the proposals listing
- return redirect(url_for('proposal.index'))
+ flash(f"Error submitting proposal: {str(e)}", "error")
+ # After proposing show the proposals listing
+ return redirect(url_for("proposal.index"))
+
-@proposal_bp.route('/update/', methods=['GET', 'POST'])
+@login_required
+@proposal_bp.route("/update/", methods=["GET", "POST"])
def proposal_detail(proposal_id):
- if request.method == 'POST':
+ if request.method == "POST":
# Read form values and delegate update to ProposalService
- name = request.form.get('name')
- website_url = request.form.get('website_url')
- country = request.form.get('country')
- description = request.form.get('description')
+ name = request.form.get("name")
+ website_url = request.form.get("website_url")
+ country = request.form.get("country")
+ description = request.form.get("description")
# Accept either 'image' (form) or 'image_url' (tests/clients)
- image = request.form.get('image_url') or request.form.get('image') or None
+ image = request.form.get("image_url") or request.form.get("image") or None
update_dto = ProposalUpdateRequest(
name=name,
website_url=website_url,
country=country,
description=description,
- image=image
+ image=image,
)
proposal_service: ProposalService = get_proposal_service()
try:
proposal_service.update_proposal(proposal_id, update_dto)
- flash('Proposal updated successfully', 'success')
+ flash("Proposal updated successfully", "success")
except Exception as e:
- flash(f'Failed to update proposal: {str(e)}', 'error')
+ flash(f"Failed to update proposal: {str(e)}", "error")
- return redirect(url_for('proposal.index'))
+ return redirect(url_for("proposal.index"))
"""Display proposal details and validation status."""
proposal_repo = get_proposal_repo()
-
+
proposal = proposal_repo.find_by_id(proposal_id)
if not proposal:
- flash('Proposal not found', 'error')
- return redirect(url_for('proposal.index'))
+ flash("Proposal not found", "error")
+ return redirect(url_for("proposal.index"))
- return render_template('proposal_detail.html',proposal=proposal)
+ return render_template("proposal_detail.html", proposal=proposal)
-@proposal_bp.route('/approve/', methods=['POST'])
+# only admin users can approve proposals
+@admin_required
+@proposal_bp.route("/approve/", methods=["POST"])
def approve_proposal(proposal_id):
"""Approve and convert proposal to radio source."""
radio_source_service = get_radio_source_service()
-
+
try:
success: RadioSource = radio_source_service.save_from_proposal(proposal_id)
if success:
- flash('Proposal approved and added as radio source!', 'success')
+ flash("Proposal approved and added as radio source!", "success")
else:
- flash('Failed to approve proposal', 'error')
+ flash("Failed to approve proposal", "error")
except Exception as e:
- flash(f'Error approving proposal: {str(e)}', 'error')
+ flash(f"Error approving proposal: {str(e)}", "error")
- return redirect(url_for('proposal.index'))
+ return redirect(url_for("proposal.index"))
diff --git a/route/radio_source_route.py b/route/radio_source_route.py
index cb36cdb..d5ca2a3 100644
--- a/route/radio_source_route.py
+++ b/route/radio_source_route.py
@@ -4,81 +4,93 @@
"""
from typing import List
-from flask import Blueprint, request,render_template, redirect, url_for, flash
+from flask import Blueprint, request, render_template, redirect, url_for, flash
from model.entity.stream_type import StreamType
from model.repository.stream_type_repository import StreamTypeRepository
+from service.authorization import admin_required
from service.radio_source_service import RadioSourceService
from model.repository.radio_source_repository import RadioSourceRepository
from database import db
-radio_source_bp = Blueprint('radio_source', __name__, url_prefix='/source')
+radio_source_bp = Blueprint("radio_source", __name__, url_prefix="/source")
+
# Repository and service initialization functions
def get_radio_source_repo() -> RadioSourceRepository:
from database import db
+
return RadioSourceRepository(db.session)
+
def get_radio_source_service() -> RadioSourceService:
radio_source_repo = get_radio_source_repo()
from service.radio_source_service import RadioSourceService
- return RadioSourceService(None, radio_source_repo, None) # Validation service will be injected if needed
+
+ return RadioSourceService(
+ None, radio_source_repo, None
+ ) # Validation service will be injected if needed
+
def get_stream_type_repo() -> StreamTypeRepository:
from database import db
+
return StreamTypeRepository(db.session)
-@radio_source_bp.route('/')
+@radio_source_bp.route("/")
def source_detail(source_id):
"""Display radio source details."""
radio_source_repo = get_radio_source_repo()
source = radio_source_repo.find_by_id(source_id)
if not source:
- flash('Radio source not found', 'error')
- return redirect(url_for('main.index'))
+ flash("Radio source not found", "error")
+ return redirect(url_for("main.index"))
- return render_template('source_detail.html', source=source)
+ return render_template("source_detail.html", source=source)
-@radio_source_bp.route('/edit/', methods=['GET', 'POST'])
+# only admin users can edit or delete radio sources
+@admin_required
+@radio_source_bp.route("/edit/", methods=["GET", "POST"])
def edit_source(source_id):
"""Edit radio source."""
radio_source_repo: RadioSourceRepository = get_radio_source_repo()
stream_type_repo: StreamTypeRepository = get_stream_type_repo()
source = radio_source_repo.find_by_id(source_id)
-
+
if not source:
- flash('Radio source not found', 'error')
- return redirect(url_for('main.index'))
-
- if request.method == 'POST':
- source.name = request.form.get('name')
- source.stream_url = request.form.get('url')
- source.description = request.form.get('description', '')
- source.stream_type_id = int(request.form.get('stream_type_id'))
+ flash("Radio source not found", "error")
+ return redirect(url_for("main.index"))
+
+ if request.method == "POST":
+ source.name = request.form.get("name")
+ source.stream_url = request.form.get("url")
+ source.description = request.form.get("description", "")
+ source.stream_type_id = int(request.form.get("stream_type_id"))
try:
radio_source_repo.save(source)
- flash('Radio source updated successfully!', 'success')
- return redirect(url_for('radio_source.source_detail', source_id=source.id))
-
+ flash("Radio source updated successfully!", "success")
+ return redirect(url_for("radio_source.source_detail", source_id=source.id))
+
except Exception as e:
- flash(f'Error updating source: {str(e)}', 'error')
+ flash(f"Error updating source: {str(e)}", "error")
# For GET request, show edit form
stream_types: List[StreamType] = stream_type_repo.find_all()
- return render_template('edit_source.html', source=source, stream_types=stream_types)
+ return render_template("edit_source.html", source=source, stream_types=stream_types)
-@radio_source_bp.route('/delete/', methods=['POST'])
+@admin_required
+@radio_source_bp.route("/delete/", methods=["POST"])
def delete_source(source_id):
"""Delete radio source."""
radio_source_service: RadioSourceService = get_radio_source_service()
-
+
if radio_source_service.delete_radio_source(source_id):
- flash('Radio source deleted successfully!', 'success')
+ flash("Radio source deleted successfully!", "success")
else:
- flash('Error deleting source', 'error')
+ flash("Error deleting source", "error")
- return redirect(url_for('main.index'))
+ return redirect(url_for("main.index"))
diff --git a/scripts/smoke_auth_check.py b/scripts/smoke_auth_check.py
index 84c292e..e34a84c 100644
--- a/scripts/smoke_auth_check.py
+++ b/scripts/smoke_auth_check.py
@@ -7,15 +7,17 @@
from app import app
+
def fetch(path):
with app.test_client() as c:
r = c.get(path)
print(f"GET {path} -> {r.status_code}, {len(r.data)} bytes")
- snippet = r.data.decode('utf-8', errors='replace')[:400]
- print('--- snippet ---')
+ snippet = r.data.decode("utf-8", errors="replace")[:400]
+ print("--- snippet ---")
print(snippet)
- print('--- end ---\n')
+ print("--- end ---\n")
+
-if __name__ == '__main__':
- fetch('/auth/login')
- fetch('/auth/register')
+if __name__ == "__main__":
+ fetch("/auth/login")
+ fetch("/auth/register")
diff --git a/service/__init__.py b/service/__init__.py
index ce08e1c..50f12c1 100644
--- a/service/__init__.py
+++ b/service/__init__.py
@@ -5,4 +5,4 @@
from .stream_analysis_service import StreamAnalysisService
from .stream_type_service import StreamTypeService
-__all__ = ["StreamAnalysisService", "StreamTypeService"]
\ No newline at end of file
+__all__ = ["StreamAnalysisService", "StreamTypeService"]
diff --git a/service/auth_service.py b/service/auth_service.py
index b5b95a9..de55ef5 100644
--- a/service/auth_service.py
+++ b/service/auth_service.py
@@ -6,7 +6,9 @@
# Prefer a pure-Python secure scheme for portability in dev/test; keep bcrypt available
# Use pbkdf2_sha256 as default to avoid system bcrypt C-extension issues in some environments
-pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt"], default="pbkdf2_sha256", deprecated="auto")
+pwd_context = CryptContext(
+ schemes=["pbkdf2_sha256", "bcrypt"], default="pbkdf2_sha256", deprecated="auto"
+)
class AuthService:
@@ -18,7 +20,7 @@ def __init__(self, app=None):
def init_app(self, app):
lm = LoginManager()
- lm.login_view = 'auth.login'
+ lm.login_view = "auth.login"
lm.init_app(app)
@lm.user_loader
@@ -35,7 +37,7 @@ def verify_password(self, plain: str, hashed: str) -> (bool, str | None):
Verify password; return (verified, new_hash_or_none)
new_hash_or_none is non-None when the hash should be updated (lazy upgrade)
"""
-
+
new_hash: str | None = None
try:
# verify_and_update returns (bool, new_hash)
@@ -47,12 +49,12 @@ def verify_password(self, plain: str, hashed: str) -> (bool, str | None):
return verified, new_hash
- def register_user(self, email: str, password: str, role: str = 'user') -> User:
+ def register_user(self, email: str, password: str, role: str = "user") -> User:
existing: User | None = self.user_repo.find_by_email(email)
-
+
if existing:
- raise ValueError('Email already registered')
-
+ raise ValueError("Email already registered")
+
hashed: str = self.hash_password(password)
return self.user_repo.create(email=email, hash_password=hashed, role=role)
diff --git a/service/authorization.py b/service/authorization.py
new file mode 100644
index 0000000..783d431
--- /dev/null
+++ b/service/authorization.py
@@ -0,0 +1,15 @@
+from functools import wraps
+from flask import abort
+from flask_login import current_user, login_required
+
+
+# decorator for role-based access control
+def admin_required(func):
+ @wraps(func)
+ @login_required
+ def wrapper(*args, **kwargs):
+ if not getattr(current_user, "is_admin", False):
+ abort(403)
+ return func(*args, **kwargs)
+
+ return wrapper
diff --git a/service/proposal_service.py b/service/proposal_service.py
index c36f04f..6538554 100644
--- a/service/proposal_service.py
+++ b/service/proposal_service.py
@@ -15,7 +15,9 @@ class ProposalService:
def __init__(self, proposal_repo: ProposalRepository):
self.proposal_repo = proposal_repo
- def update_proposal(self, proposal_id: int, updates: ProposalUpdateRequest) -> Proposal:
+ def update_proposal(
+ self, proposal_id: int, updates: ProposalUpdateRequest
+ ) -> Proposal:
"""Update editable fields of a proposal and persist changes.
Editable fields: name, website_url, country, description, image (mapped to image_url)
diff --git a/service/proposal_validation_service.py b/service/proposal_validation_service.py
index 68487e7..9dc477c 100644
--- a/service/proposal_validation_service.py
+++ b/service/proposal_validation_service.py
@@ -15,53 +15,53 @@
class ProposalValidationService:
"""
Service for validating proposals before saving to RadioSourceNode.
-
+
Validates:
- Required fields presence
- URL format validity
- Duplicate stream URLs
- Security status
"""
-
+
def __init__(
- self,
+ self,
proposal_repo: ProposalRepository,
- radio_source_repo: RadioSourceRepository
+ radio_source_repo: RadioSourceRepository,
):
self.proposal_repo = proposal_repo
self.radio_source_repo = radio_source_repo
-
+
def validate_proposal(self, proposal_id: int) -> ValidationResult:
"""
Validate a proposal before saving to RadioSourceNode.
-
+
Checks:
- Proposal exists
- Required fields are present and non-empty
- URLs are valid format
- Stream URL is not duplicate
-
+
Args:
proposal_id: ID of the proposal to validate
-
+
Returns:
ValidationResult with is_valid flag and error/warning messages
"""
result = ValidationResult(is_valid=True)
-
+
# Check proposal exists
proposal = self.proposal_repo.find_by_id(proposal_id)
if not proposal:
result.add_error(f"Proposal with ID {proposal_id} not found")
return result
-
+
# Validate required fields (FR-006)
if not str(proposal.stream_url) or not str(proposal.stream_url).strip():
result.add_error("Stream URL is required and cannot be empty")
-
+
if not str(proposal.name) or not str(proposal.name).strip():
result.add_error("Name is required and cannot be empty")
-
+
if not str(proposal.website_url) or not str(proposal.website_url).strip():
result.add_error("Website URL is required and cannot be empty")
@@ -69,46 +69,50 @@ def validate_proposal(self, proposal_id: int) -> ValidationResult:
# Proposals created from discovery (spec 001) may not have this yet; they
# must be classified (spec 003) before being saved to the radio sources table.
if proposal.stream_type_id is None:
- result.add_error("Stream type is required. Please classify the stream before saving.")
-
+ result.add_error(
+ "Stream type is required. Please classify the stream before saving."
+ )
+
# Validate URL formats
if str(proposal.stream_url):
if not self._is_valid_url(str(proposal.stream_url)):
result.add_error(f"Invalid stream URL format: {proposal.stream_url}")
-
+
if str(proposal.website_url):
if not self._is_valid_url(str(proposal.website_url)):
result.add_error(f"Invalid website URL format: {proposal.website_url}")
-
+
# Check for duplicate stream URL (FR-005)
- if str(proposal.stream_url) and self.check_duplicate_stream_url(str(proposal.stream_url)):
+ if str(proposal.stream_url) and self.check_duplicate_stream_url(
+ str(proposal.stream_url)
+ ):
result.add_error("This stream URL already exists in the database")
-
+
# Add security warning if HTTP stream
if str(proposal.stream_url) and not bool(proposal.is_secure):
result.add_warning("This stream uses HTTP (not secure)")
-
+
return result
-
+
def check_duplicate_stream_url(self, stream_url: str) -> bool:
"""
Check if a stream URL already exists in RadioSourceNode table.
-
+
Args:
stream_url: The stream URL to check
-
+
Returns:
True if duplicate found, False otherwise
"""
return self.radio_source_repo.find_by_url(stream_url) is not None
-
+
def get_security_status(self, proposal_id: int) -> Optional[SecurityStatus]:
"""
Get the security status of a proposal.
-
+
Args:
proposal_id: ID of the proposal
-
+
Returns:
SecurityStatus object with is_secure flag and warning message,
or None if proposal not found
@@ -116,22 +120,22 @@ def get_security_status(self, proposal_id: int) -> Optional[SecurityStatus]:
proposal = self.proposal_repo.find_by_id(proposal_id)
if not proposal:
return None
-
+
return SecurityStatus.from_is_secure(proposal.is_secure)
-
+
def _is_valid_url(self, url: str) -> bool:
"""
Validate URL format.
-
+
Args:
url: URL string to validate
-
+
Returns:
True if URL has valid format, False otherwise
"""
try:
parsed = urlparse(url)
# Must have scheme (http/https only) and netloc (domain)
- return bool(parsed.scheme in ['http', 'https'] and parsed.netloc)
+ return bool(parsed.scheme in ["http", "https"] and parsed.netloc)
except Exception:
return False
diff --git a/service/radio_source_service.py b/service/radio_source_service.py
index 673ffd7..e25027b 100644
--- a/service/radio_source_service.py
+++ b/service/radio_source_service.py
@@ -19,39 +19,39 @@
class RadioSourceService:
"""
Service for managing RadioSourceNodes and their lifecycle.
-
+
Handles:
- Saving proposals as RadioSourceNodes
- Rejecting proposals
- Updating proposal data
"""
-
+
def __init__(
self,
proposal_repo: ProposalRepository,
radio_source_repo: RadioSourceRepository,
- validation_service: ProposalValidationService
+ validation_service: ProposalValidationService,
):
self.proposal_repo = proposal_repo
self.radio_source_repo = radio_source_repo
self.validation_service = validation_service
-
+
def save_from_proposal(self, proposal_id: int) -> RadioSource:
"""
Save a proposal as a RadioSourceNode in the database.
-
+
This method:
1. Validates the proposal
2. Creates a RadioSourceNode from proposal data
3. Saves to database
4. Deletes the proposal (single transaction)
-
+
Args:
proposal_id: ID of the proposal to save
-
+
Returns:
The saved RadioSourceNode
-
+
Raises:
ValueError: If validation fails or proposal not found
RuntimeError: If database operation fails
@@ -61,12 +61,12 @@ def save_from_proposal(self, proposal_id: int) -> RadioSource:
if not validation_result.is_valid:
error_msg = "; ".join(validation_result.errors)
raise ValueError(f"Proposal validation failed: {error_msg}")
-
+
# Get proposal
proposal = self.proposal_repo.find_by_id(proposal_id)
if not proposal:
raise ValueError(f"Proposal with ID {proposal_id} not found")
-
+
# Create RadioSourceNode from proposal
radio_source = RadioSource(
stream_url=proposal.stream_url,
@@ -77,36 +77,37 @@ def save_from_proposal(self, proposal_id: int) -> RadioSource:
country=proposal.country,
description=proposal.description,
image_url=proposal.image_url,
- created_at=datetime.now()
+ created_at=datetime.now(),
)
-
+
try:
# Save RadioSourceNode (this will commit the transaction)
saved_source = self.radio_source_repo.save(radio_source)
-
+
# Delete proposal after successful save
self.proposal_repo.delete(proposal_id)
-
+
return saved_source
-
+
except Exception as e:
raise RuntimeError(f"Failed to save radio source: {str(e)}")
-
-
- def update_proposal(self, proposal_id: int, updates: ProposalUpdateRequest) -> Proposal:
+
+ def update_proposal(
+ self, proposal_id: int, updates: ProposalUpdateRequest
+ ) -> Proposal:
"""
Update user-editable fields of a proposal.
-
+
Only allows updating: name, website_url, country, description, image
Read-only fields (stream_type_id, is_secure) cannot be modified.
-
+
Args:
proposal_id: ID of the proposal to update
updates: ProposalUpdateRequest with fields to update
-
+
Returns:
Updated Proposal
-
+
Raises:
ValueError: If proposal not found or no updates provided
"""
@@ -114,69 +115,65 @@ def update_proposal(self, proposal_id: int, updates: ProposalUpdateRequest) -> P
proposal = self.proposal_repo.find_by_id(proposal_id)
if not proposal:
raise ValueError(f"Proposal with ID {proposal_id} not found")
-
+
# Check if any updates provided
if not updates.has_updates():
raise ValueError("No updates provided")
-
+
# Update only user-editable fields
if updates.name is not None:
proposal.name = updates.name
-
+
if updates.website_url is not None:
proposal.website_url = updates.website_url
-
+
if updates.country is not None:
proposal.country = updates.country
-
+
if updates.description is not None:
proposal.description = updates.description
-
+
if updates.image is not None:
proposal.image = updates.image
-
+
# Save and return updated proposal
return self.proposal_repo.save(proposal)
-
def get_proposal(self, proposal_id: int) -> Optional[Proposal]:
"""
Get a proposal by ID.
-
+
Args:
proposal_id: ID of the proposal
-
+
Returns:
Proposal if found, None otherwise
"""
return self.proposal_repo.find_by_id(proposal_id)
-
def get_all_proposals(self) -> list[Proposal]:
"""
Get all proposals.
-
+
Returns:
List of all proposals
"""
return self.proposal_repo.get_all_proposals()
-
def get_all_radio_sources(self) -> list[RadioSource]:
"""
Get all radio sources.
-
+
Returns:
List of all radio sources
"""
return self.radio_source_repo.find_all()
def delete_radio_source(self, id) -> bool:
- """ delete a radio source"""
+ """delete a radio source"""
if id:
return self.radio_source_repo.delete(id)
return False
-
def reject_proposal(self, proposal_id: int) -> bool:
"""
@@ -212,4 +209,3 @@ def reject_proposal(self, proposal_id: int) -> bool:
return False
except Exception:
return False
-
diff --git a/service/stream_analysis_service.py b/service/stream_analysis_service.py
index 79e76e4..60b500e 100644
--- a/service/stream_analysis_service.py
+++ b/service/stream_analysis_service.py
@@ -1,7 +1,7 @@
"""
StreamAnalysisService - Core implementation of spec 003 analyze-and-classify-stream.
-This service implements the dual validation strategy (curl + ffmpeg) and
+This service implements the dual validation strategy (curl + ffmpeg) and
classification logic as defined in the specification.
"""
@@ -18,17 +18,23 @@
from model.repository.proposal_repository import ProposalRepository
from model.entity.stream_analysis import StreamAnalysis
+
class StreamAnalysisService:
"""
Core service implementing spec 003: analyze-and-classify-stream
-
+
Performs dual validation:
1. curl -I for headers/content-type analysis
2. ffmpeg -i for deep format analysis
3. FFmpeg is authoritative when results differ
"""
-
- def __init__(self, stream_type_service: StreamTypeService, proposal_repository: ProposalRepository, analysis_repository: StreamAnalysisRepository):
+
+ def __init__(
+ self,
+ stream_type_service: StreamTypeService,
+ proposal_repository: ProposalRepository,
+ analysis_repository: StreamAnalysisRepository,
+ ):
self.stream_type_service: StreamTypeService = stream_type_service
self.proposal_repository: ProposalRepository = proposal_repository
self.analysis_repository: StreamAnalysisRepository = analysis_repository
@@ -40,19 +46,25 @@ def _check_prerequisites(self) -> None:
Raises RuntimeError if prerequisites are not met.
"""
if not shutil.which("ffmpeg"):
- raise RuntimeError("ffmpeg is not installed or not accessible in PATH. Required for stream analysis.")
-
+ raise RuntimeError(
+ "ffmpeg is not installed or not accessible in PATH. Required for stream analysis."
+ )
+
if not shutil.which("curl"):
- raise RuntimeError("curl is not installed or not accessible in PATH. Required for header analysis.")
-
- def analyze_stream(self, url: str, timeout_seconds: int = 30) -> StreamAnalysisResult:
+ raise RuntimeError(
+ "curl is not installed or not accessible in PATH. Required for header analysis."
+ )
+
+ def analyze_stream(
+ self, url: str, timeout_seconds: int = 30
+ ) -> StreamAnalysisResult:
"""
Main entry point for stream analysis (spec 003).
-
+
Args:
url: The stream URL to analyze
timeout_seconds: Maximum time to spend on analysis (default: 30s as per SC-001)
-
+
Returns:
StreamAnalysisResult with validation and classification data
"""
@@ -63,50 +75,52 @@ def analyze_stream(self, url: str, timeout_seconds: int = 30) -> StreamAnalysisR
stream_url=url,
is_valid=False,
is_secure=False,
- error_code=ErrorCode.UNSUPPORTED_PROTOCOL
+ error_code=ErrorCode.UNSUPPORTED_PROTOCOL,
)
-
+
# Determine if URL is secure (HTTPS vs HTTP)
is_secure = self._is_secure_url(url)
-
+
try:
# FR-002: Perform dual validation
curl_result = self._analyze_with_curl(url, timeout_seconds)
ffmpeg_result = self._analyze_with_ffmpeg(url, timeout_seconds)
-
+
# FR-003: Compare results, ffmpeg is authoritative
- final_result: StreamAnalysisResult = self._resolve_analysis_results(curl_result, ffmpeg_result, is_secure)
+ final_result: StreamAnalysisResult = self._resolve_analysis_results(
+ curl_result, ffmpeg_result, is_secure
+ )
# print("Analysis result for URL {}: {}".format(url, final_result))
return final_result
-
+
except subprocess.TimeoutExpired:
return StreamAnalysisResult(
stream_url=url,
is_valid=False,
is_secure=is_secure,
- error_code=ErrorCode.TIMEOUT
+ error_code=ErrorCode.TIMEOUT,
)
except Exception:
return StreamAnalysisResult(
stream_url=url,
is_valid=False,
is_secure=is_secure,
- error_code=ErrorCode.NETWORK_ERROR
+ error_code=ErrorCode.NETWORK_ERROR,
)
-
+
def _is_supported_protocol(self, url: str) -> bool:
"""Check if the URL uses a supported protocol (HTTP/HTTPS only)."""
parsed = urlparse(url)
- return parsed.scheme.lower() in ['http', 'https']
-
+ return parsed.scheme.lower() in ["http", "https"]
+
def _is_secure_url(self, url: str) -> bool:
"""Determine if URL is secure (HTTPS = true, HTTP = false)."""
- return urlparse(url).scheme.lower() == 'https'
-
+ return urlparse(url).scheme.lower() == "https"
+
def _analyze_with_curl(self, url: str, timeout_seconds: int) -> Dict[str, Any]:
"""
Analyze stream using curl -I to get headers and content-type.
-
+
Returns:
Dict with 'success', 'content_type', 'raw_output' keys
"""
@@ -116,30 +130,34 @@ def _analyze_with_curl(self, url: str, timeout_seconds: int) -> Dict[str, Any]:
capture_output=True,
text=True,
timeout=timeout_seconds,
- check=False
+ check=False,
)
-
+
if result.returncode != 0:
- return {"success": False, "content_type": None, "raw_output": result.stderr}
-
+ return {
+ "success": False,
+ "content_type": None,
+ "raw_output": result.stderr,
+ }
+
# Extract content-type from headers
content_type = self._extract_content_type(result.stdout)
-
+
return {
"success": True,
"content_type": content_type,
- "raw_output": result.stdout
+ "raw_output": result.stdout,
}
-
+
except subprocess.TimeoutExpired:
raise
except Exception as e:
return {"success": False, "content_type": None, "raw_output": str(e)}
-
+
def _analyze_with_ffmpeg(self, url: str, timeout_seconds: int) -> Dict[str, Any]:
"""
Analyze stream using ffmpeg -i for deep format analysis.
-
+
Returns:
Dict with 'success', 'format', 'codec', 'raw_output' keys
"""
@@ -149,9 +167,9 @@ def _analyze_with_ffmpeg(self, url: str, timeout_seconds: int) -> Dict[str, Any]
capture_output=True,
text=True,
timeout=timeout_seconds,
- check=False
+ check=False,
)
-
+
# ffmpeg writes info to stderr, not stdout
output = result.stderr
@@ -166,51 +184,48 @@ def _analyze_with_ffmpeg(self, url: str, timeout_seconds: int) -> Dict[str, Any]
"format": format_info.get("format") if format_info else None,
"codec": format_info.get("codec") if format_info else None,
"raw_output": output,
- "extracted_metadata": extracted_metadata
+ "extracted_metadata": extracted_metadata,
}
-
+
except subprocess.TimeoutExpired:
raise
except Exception as e:
- return {"success": False, "format": None, "codec": None, "raw_output": str(e)}
-
+ return {
+ "success": False,
+ "format": None,
+ "codec": None,
+ "raw_output": str(e),
+ }
+
def _extract_content_type(self, headers: str) -> Optional[str]:
"""Extract content-type from HTTP headers."""
# Handle both actual newlines and literal \n in headers
- lines = headers.replace('\\n', '\n').split('\n')
+ lines = headers.replace("\\n", "\n").split("\n")
for line in lines:
- if line.lower().startswith('content-type:'):
- return line.split(':', 1)[1].strip()
+ if line.lower().startswith("content-type:"):
+ return line.split(":", 1)[1].strip()
return None
-
+
def _parse_ffmpeg_output(self, output: str) -> Optional[Dict[str, str]]:
"""
Parse ffmpeg output to extract format and codec information.
-
+
Example ffmpeg output:
"Stream #0:0: Audio: mp3 (mp3float), 22050 Hz, mono, fltp, 24 kb/s"
"""
# Look for audio stream information
- audio_match = re.search(r'Stream #\d+:\d+: Audio: (\w+)', output)
+ audio_match = re.search(r"Stream #\d+:\d+: Audio: (\w+)", output)
if not audio_match:
return None
-
+
codec = audio_match.group(1).lower()
-
+
# Map ffmpeg codec names to our format names
- format_mapping = {
- 'mp3': 'MP3',
- 'aac': 'AAC',
- 'ogg': 'OGG',
- 'vorbis': 'OGG'
- }
-
+ format_mapping = {"mp3": "MP3", "aac": "AAC", "ogg": "OGG", "vorbis": "OGG"}
+
format_name = format_mapping.get(codec, codec.upper())
-
- return {
- "format": format_name,
- "codec": codec
- }
+
+ return {"format": format_name, "codec": codec}
def _extract_metadata_from_ffmpeg_output(self, output: str) -> Optional[str]:
"""
@@ -227,17 +242,20 @@ def _extract_metadata_from_ffmpeg_output(self, output: str) -> Optional[str]:
if not output or not isinstance(output, str):
return None
- norm = output.replace('\r\n', '\n').replace('\r', '\n')
+ norm = output.replace("\r\n", "\n").replace("\r", "\n")
# Find all 'Metadata:' markers; choose the last one
- meta_matches = [m.start() for m in re.finditer(r"^\s*Metadata:\s*$", norm, flags=re.MULTILINE)]
+ meta_matches = [
+ m.start()
+ for m in re.finditer(r"^\s*Metadata:\s*$", norm, flags=re.MULTILINE)
+ ]
if not meta_matches:
return None
# Start scanning from the last metadata marker
last_pos = meta_matches[-1]
tail = norm[last_pos:]
- lines = tail.split('\n')
+ lines = tail.split("\n")
# Skip the 'Metadata:' line itself
captured = []
@@ -252,8 +270,8 @@ def _extract_metadata_from_ffmpeg_output(self, output: str) -> Optional[str]:
# normalize: remove leading whitespace, normalize spacing around ':'
stripped = line.strip()
# replace multiple spaces around colon
- if ':' in stripped:
- parts = stripped.split(':', 1)
+ if ":" in stripped:
+ parts = stripped.split(":", 1)
key = parts[0].strip()
val = parts[1].strip()
captured.append(f"{key}: {val}")
@@ -268,13 +286,15 @@ def _extract_metadata_from_ffmpeg_output(self, output: str) -> Optional[str]:
joined = "\n".join(captured)
# Remove control chars except newline and tab
- cleaned = ''.join(ch for ch in joined if (ch >= ' ' or ch in '\n\t'))
+ cleaned = "".join(ch for ch in joined if (ch >= " " or ch in "\n\t"))
cleaned = cleaned.strip()
if len(cleaned) > 4096:
cleaned = cleaned[:4096]
return cleaned
-
- def _resolve_analysis_results(self, curl_result: dict, ffmpeg_result: dict, is_secure: bool) -> StreamAnalysisResult:
+
+ def _resolve_analysis_results(
+ self, curl_result: dict, ffmpeg_result: dict, is_secure: bool
+ ) -> StreamAnalysisResult:
"""
Resolve analysis results from curl and ffmpeg.
FR-003: FFmpeg is authoritative when results differ.
@@ -285,55 +305,75 @@ def _resolve_analysis_results(self, curl_result: dict, ffmpeg_result: dict, is_s
return self._classify_from_curl(curl_result, is_secure)
else:
return StreamAnalysisResult(
- stream_url=curl_result["stream_url"] if "stream_url" in curl_result else None,
+ stream_url=(
+ curl_result["stream_url"]
+ if "stream_url" in curl_result
+ else None
+ ),
is_valid=False,
is_secure=is_secure,
error_code=ErrorCode.UNREACHABLE,
raw_content_type=curl_result.get("raw_output"),
raw_ffmpeg_output=ffmpeg_result.get("raw_output"),
- extracted_metadata=ffmpeg_result.get("extracted_metadata")
+ extracted_metadata=ffmpeg_result.get("extracted_metadata"),
)
-
+
# FFmpeg succeeded - use it as authoritative source
format_name = ffmpeg_result["format"]
if not format_name:
return StreamAnalysisResult(
is_valid=False,
- stream_url=ffmpeg_result["stream_url"] if "stream_url" in ffmpeg_result else None,
+ stream_url=(
+ ffmpeg_result["stream_url"]
+ if "stream_url" in ffmpeg_result
+ else None
+ ),
is_secure=is_secure,
error_code=ErrorCode.INVALID_FORMAT,
raw_content_type=curl_result.get("raw_output"),
raw_ffmpeg_output=ffmpeg_result.get("raw_output"),
- extracted_metadata=ffmpeg_result.get("extracted_metadata")
+ extracted_metadata=ffmpeg_result.get("extracted_metadata"),
)
-
+
# Determine protocol (HTTP/HTTPS based on URL, or HLS if m3u8 detected)
protocol = "HTTPS" if is_secure else "HTTP"
if ".m3u8" in ffmpeg_result.get("raw_output", "").lower():
protocol = "HLS"
-
+
# Detect metadata support (basic heuristic - could be enhanced)
metadata = self._detect_metadata_support(curl_result.get("raw_output", ""))
-
+
# Find matching StreamType
- stream_type_id = self.stream_type_service.find_stream_type_id(protocol, format_name, metadata)
-
- detection_method = DetectionMethod.BOTH if curl_result["success"] else DetectionMethod.FFMPEG
-
+ stream_type_id = self.stream_type_service.find_stream_type_id(
+ protocol, format_name, metadata
+ )
+
+ detection_method = (
+ DetectionMethod.BOTH if curl_result["success"] else DetectionMethod.FFMPEG
+ )
+
return StreamAnalysisResult(
is_valid=stream_type_id is not None,
stream_type_id=stream_type_id,
- stream_type_display_name=self.stream_type_service.get_display_name(stream_type_id) if stream_type_id else None,
- stream_url=ffmpeg_result["stream_url"] if "stream_url" in ffmpeg_result else None,
+ stream_type_display_name=(
+ self.stream_type_service.get_display_name(stream_type_id)
+ if stream_type_id
+ else None
+ ),
+ stream_url=(
+ ffmpeg_result["stream_url"] if "stream_url" in ffmpeg_result else None
+ ),
is_secure=is_secure,
error_code=None,
detection_method=detection_method,
raw_content_type=curl_result.get("raw_output"),
raw_ffmpeg_output=ffmpeg_result.get("raw_output"),
- extracted_metadata=ffmpeg_result.get("extracted_metadata")
+ extracted_metadata=ffmpeg_result.get("extracted_metadata"),
)
-
- def _classify_from_curl(self, curl_result: dict, is_secure: bool) -> StreamAnalysisResult:
+
+ def _classify_from_curl(
+ self, curl_result: dict, is_secure: bool
+ ) -> StreamAnalysisResult:
"""Classify stream based only on curl content-type headers."""
content_type = curl_result["content_type"]
if not content_type:
@@ -341,9 +381,9 @@ def _classify_from_curl(self, curl_result: dict, is_secure: bool) -> StreamAnaly
is_valid=False,
is_secure=is_secure,
error_code=ErrorCode.INVALID_FORMAT,
- raw_content_type=curl_result.get("raw_output")
+ raw_content_type=curl_result.get("raw_output"),
)
-
+
# Map content-type to format
format_name = None
if "audio/mpeg" in content_type.lower() or "audio/mp3" in content_type.lower():
@@ -356,39 +396,51 @@ def _classify_from_curl(self, curl_result: dict, is_secure: bool) -> StreamAnaly
# HLS playlist
protocol = "HLS"
format_name = "AAC" # Most common for HLS
- stream_type_id = self.stream_type_service.find_stream_type_id(protocol, format_name, "None")
+ stream_type_id = self.stream_type_service.find_stream_type_id(
+ protocol, format_name, "None"
+ )
return StreamAnalysisResult(
is_valid=stream_type_id is not None,
stream_type_id=stream_type_id,
- stream_type_display_name=self.stream_type_service.get_display_name(stream_type_id) if stream_type_id else None,
+ stream_type_display_name=(
+ self.stream_type_service.get_display_name(stream_type_id)
+ if stream_type_id
+ else None
+ ),
stream_url=curl_result.get("stream_url", None),
is_secure=is_secure,
detection_method=DetectionMethod.HEADER,
- raw_content_type=curl_result.get("raw_output")
+ raw_content_type=curl_result.get("raw_output"),
)
-
+
if not format_name:
return StreamAnalysisResult(
is_valid=False,
is_secure=is_secure,
error_code=ErrorCode.INVALID_FORMAT,
- raw_content_type=curl_result.get("raw_output")
+ raw_content_type=curl_result.get("raw_output"),
)
-
+
protocol = "HTTPS" if is_secure else "HTTP"
metadata = self._detect_metadata_support(curl_result.get("raw_output", ""))
- stream_type_id = self.stream_type_service.find_stream_type_id(protocol, format_name, metadata)
-
+ stream_type_id = self.stream_type_service.find_stream_type_id(
+ protocol, format_name, metadata
+ )
+
return StreamAnalysisResult(
is_valid=stream_type_id is not None,
stream_type_id=stream_type_id,
- stream_type_display_name=self.stream_type_service.get_display_name(stream_type_id) if stream_type_id else None,
+ stream_type_display_name=(
+ self.stream_type_service.get_display_name(stream_type_id)
+ if stream_type_id
+ else None
+ ),
stream_url=curl_result.get("stream_url", None),
is_secure=is_secure,
detection_method=DetectionMethod.HEADER,
- raw_content_type=curl_result.get("raw_output")
+ raw_content_type=curl_result.get("raw_output"),
)
-
+
def _detect_metadata_support(self, headers: str) -> str:
"""
Detect metadata support (Icecast/Shoutcast) from headers.
@@ -396,11 +448,11 @@ def _detect_metadata_support(self, headers: str) -> str:
"""
if not headers:
return "None"
-
+
# Handle both actual newlines and literal \n in headers
- headers_normalized = headers.replace('\\n', '\n')
+ headers_normalized = headers.replace("\\n", "\n")
headers_lower = headers_normalized.lower()
-
+
# Check for Shoutcast first (more specific)
if "shoutcast" in headers_lower:
return "Shoutcast"
@@ -408,8 +460,8 @@ def _detect_metadata_support(self, headers: str) -> str:
return "Icecast"
else:
return "None"
-
- def save_analysis_as_proposal(self, stream_or_id) -> bool:
+
+ def save_analysis_as_proposal(self, stream_or_id, created_by: int | None = None) -> bool:
"""
Approve an analysis and create a proposal for radio source.
@@ -429,15 +481,18 @@ def save_analysis_as_proposal(self, stream_or_id) -> bool:
return False
# Determine required fields
- stream_url = getattr(stream_entity, 'stream_url', None)
- stream_type_id = getattr(stream_entity, 'stream_type_id', None)
- is_secure = getattr(stream_entity, 'is_secure', False)
- is_valid = getattr(stream_entity, 'is_valid', True)
+ stream_url = getattr(stream_entity, "stream_url", None)
+ stream_type_id = getattr(stream_entity, "stream_type_id", None)
+ is_secure = getattr(stream_entity, "is_secure", False)
+ is_valid = getattr(stream_entity, "is_valid", True)
if not is_valid or not stream_url or not stream_type_id:
print("Cannot create proposal for invalid analysis or missing data.")
return False
+ # Determine created_by: prefer explicit arg, then analysis entity attribute if present
+ resolved_created_by = created_by if created_by is not None else getattr(stream_entity, "created_by", None)
+
proposal = Proposal(
stream_url=stream_url,
name="",
@@ -447,6 +502,7 @@ def save_analysis_as_proposal(self, stream_or_id) -> bool:
image_url=None,
stream_type_id=stream_type_id,
is_secure=is_secure,
+ created_by=resolved_created_by,
created_at=date.today(),
)
@@ -460,7 +516,7 @@ def save_analysis_as_proposal(self, stream_or_id) -> bool:
if isinstance(stream_or_id, int):
self.analysis_repository.delete(stream_or_id)
else:
- stream_id = getattr(stream_entity, 'id', None)
+ stream_id = getattr(stream_entity, "id", None)
if stream_id:
self.analysis_repository.delete(stream_id)
except Exception:
@@ -482,8 +538,8 @@ def delete_analysis(self, stream_or_id) -> bool:
return self.analysis_repository.delete(stream_or_id)
# If an object provided, try to get id
- stream_id = getattr(stream_or_id, 'id', None)
+ stream_id = getattr(stream_or_id, "id", None)
if stream_id:
return self.analysis_repository.delete(stream_id)
- return False
\ No newline at end of file
+ return False
diff --git a/service/stream_type_service.py b/service/stream_type_service.py
index 61b621c..7bf397b 100644
--- a/service/stream_type_service.py
+++ b/service/stream_type_service.py
@@ -10,24 +10,26 @@
class StreamTypeService:
"""Service for managing StreamType entities and lookup operations."""
-
+
def __init__(self, stream_type_repository: StreamTypeRepository):
self.repository = stream_type_repository
-
- def find_stream_type_id(self, protocol: str, format: str, metadata: str) -> Optional[int]:
+
+ def find_stream_type_id(
+ self, protocol: str, format: str, metadata: str
+ ) -> Optional[int]:
"""
Find StreamType ID for given protocol, format, and metadata combination.
-
+
Args:
protocol: HTTP, HTTPS, or HLS
format: MP3, AAC, OGG
metadata: Icecast, Shoutcast, or None
-
+
Returns:
StreamType ID if found, None otherwise
"""
return self.repository.find_by_combination(protocol, format, metadata)
-
+
def get_stream_type(self, stream_type_id: int) -> Optional[StreamTypeDTO]:
"""Get StreamType by ID."""
stream_type = self.repository.get_by_id(stream_type_id)
@@ -38,28 +40,31 @@ def get_stream_type(self, stream_type_id: int) -> Optional[StreamTypeDTO]:
protocol=stream_type.protocol,
format=stream_type.format,
metadata=stream_type.metadata_type,
- display_name=stream_type.display_name
+ display_name=stream_type.display_name,
)
return None
-
+
def get_all_stream_types(self) -> List[StreamTypeDTO]:
"""Get all available StreamTypes."""
stream_types = self.repository.get_all()
- return [StreamTypeDTO(
- id=st.id,
- protocol=st.protocol,
- format=st.format,
- metadata=st.metadata_type,
- display_name=st.display_name
- ) for st in stream_types]
-
+ return [
+ StreamTypeDTO(
+ id=st.id,
+ protocol=st.protocol,
+ format=st.format,
+ metadata=st.metadata_type,
+ display_name=st.display_name,
+ )
+ for st in stream_types
+ ]
+
def get_predefined_types_map(self) -> Dict[str, int]:
"""
Get a map of type keys (PROTOCOL-FORMAT-METADATA) to IDs.
Useful for quick lookups during analysis.
"""
return self.repository.get_type_key_to_id_map()
-
+
def initialize_predefined_types(self) -> None:
"""
Initialize the database with the 14 predefined StreamTypes from spec 003.
@@ -79,15 +84,22 @@ def initialize_predefined_types(self) -> None:
("HTTPS", "AAC", "Shoutcast", "HTTPS AAC with Shoutcast metadata"),
("HTTPS", "AAC", "None", "HTTPS AAC direct stream"),
("HLS", "AAC", "None", "HTTP Live Streaming (HLS) with AAC"),
- ("PLAYLIST", "PLAYLIST", "None", "Playlist file (.m3u, .pls, .m3u8) - parsing not implemented")
+ (
+ "PLAYLIST",
+ "PLAYLIST",
+ "None",
+ "Playlist file (.m3u, .pls, .m3u8) - parsing not implemented",
+ ),
]
-
+
for protocol, format_type, metadata, display_name in predefined_types:
- self.repository.create_if_not_exists(protocol, format_type, metadata, display_name)
+ self.repository.create_if_not_exists(
+ protocol, format_type, metadata, display_name
+ )
def get_display_name(self, stream_type_id: int) -> Optional[str]:
"""Get the display name of a StreamType by its ID."""
stream_type = self.repository.find_by_id(stream_type_id)
if stream_type:
return stream_type.display_name
- return None
\ No newline at end of file
+ return None
diff --git a/specs/feature/code_review_feat-auth_layer_apply_roles.md b/specs/feature/code_review_feat-auth_layer_apply_roles.md
new file mode 100644
index 0000000..1e83a50
--- /dev/null
+++ b/specs/feature/code_review_feat-auth_layer_apply_roles.md
@@ -0,0 +1,89 @@
+# Code review ā feat/auth layer apply roles
+
+## Overview
+
+- PR Phase 2 autenticazione: introdotti ruoli (user vs admin) e tracciamento dei creator per proposte e analisi. [file:1]
+- Applicato Black a molti file (service, route, test, app.py). [file:1]
+- Aggiornati i test per usare nuovi fixture di autenticazione e pattern con transazioni annidate. [file:1]
+
+---
+## Issues Identified
+
+* In tests/unit/test_radio_source_service.py:
+
+> @@ -158,11 +155,7 @@ def test_reject_proposal_success(
+ assert result
+ mock_proposal_repo.delete.assert_called_once_with(1)
+
+- def test_reject_proposal_not_found(
+- self,
+- radio_source_service,
+- mock_proposal_repo
+- ):
++ def test_reject_proposal_not_found(self, radio_source_service, mock_proposal_repo):
+This assignment to 'test_reject_proposal_not_found' is unnecessary as it is [1]redefined before this value is used.
+`OK SOLVED by removing the previous definition`.
+
+* In tests/conftest.py:
+
+> from database import db
+ from model.entity.stream_type import StreamType
++from model.entity.stream_analysis import StreamAnalysis
+Import of 'StreamAnalysis' is not used.
+`OK removed`
+
+* In tests/conftest.py:
+
+> from database import db
+ from model.entity.stream_type import StreamType
++from model.entity.stream_analysis import StreamAnalysis
++from model.entity.user import User
+Import of 'User' is not used.
+
+* In tests/conftest.py:
+
+> from database import db
+ from model.entity.stream_type import StreamType
++from model.entity.stream_analysis import StreamAnalysis
++from model.entity.user import User
++from model.entity.radio_source import RadioSource
+Import of 'RadioSource' is not used.
+
+* In tests/conftest.py:
+
+> from database import db
+ from model.entity.stream_type import StreamType
++from model.entity.stream_analysis import StreamAnalysis
++from model.entity.user import User
++from model.entity.radio_source import RadioSource
++from model.entity.proposal import Proposal
+Import of 'Proposal' is not used.
+`OK removed 3 entities non used`
+
+* In specs/feature/feat-auth-layer-apply-roles.md:
+
+> +1. Seed CLI (new) `scripts/create_admin.py` ā minimal safe script:
++ ```python
++ # scripts/create_admin.py
++ import argparse
++ from app import app
++ from service.auth_service import AuthService
++
++ def main():
++ p = argparse.ArgumentParser()
++ p.add_argument('--email', required=True)
++ p.add_argument('--password', required=True)
++ args = p.parse_args()
++
++ AuthService(app).register_user(email=args.email, password=args.password, role='admin')
++ print(f"Admin user '{args.email}' created.")
++
++ if __name__ == '__main__':
++ main()
++ ```
++
+
+* Run locally (do not commit secrets): `python scripts/create_admin.py --email admin@example.com --password 'Strong!'`
+The example scripts/create_admin.py in this spec passes the admin password via a --password command-line argument (lines 75ā93 and the example invocation on line 94). Command-line arguments are visible in process listings and can be stored in shell history or other monitoring logs, which can expose the admin password to other local users or tooling.
+
+Instead of accepting the password via CLI args, use a secure input method such as getpass.getpass() or read it from a protected environment variable or prompt inside the script, and update the usage example to avoid putting the password directly on the command line.
\ No newline at end of file
diff --git a/specs/feature/feat-auth-layer-apply-roles.md b/specs/feature/feat-auth-layer-apply-roles.md
index b834d21..aea945e 100644
--- a/specs/feature/feat-auth-layer-apply-roles.md
+++ b/specs/feature/feat-auth-layer-apply-roles.md
@@ -1,110 +1,158 @@
# Auth Roles ā Phase 2 Proposal
## TL;DR
-Add role-aware authorization (user vs admin). Use the existing role column on User, add `is_admin` helper and an `admin_required` decorator, enforce admin checks server-side on admin actions (approve/reject, deletes), add a small seed/CLI for creating an initial admin, update UI to surface admin controls, and add integration tests to lock the behavior. The plan keeps changes minimal and testable.
-
-## Steps (high level)
-- Create feature branch: `git checkout -b feat/auth-roles`.
-- Add `is_admin` helper to `user.py`.
-- Add an `admin_required` decorator (new file `service/authorization.py` or add to `auth_service.py`).
-- Protect admin-only endpoints (proposal approvals, analysis approve/delete, any management endpoints).
-- Add a CLI/utility script to create the initial admin account (`scripts/create_admin.py`).
-- Update templates to show/hide admin UI elements and add server-side checks for visibility only (do not rely on UI alone).
-- Add tests: integration tests to verify admin can perform admin actions and normal users cannot (403 or redirect).
-- Run full test suite and fix regressions; documentation and a short release checklist.
-
-## Files to change (suggested)
-### Add/modify:
-- `user.py` ā add `is_admin` property.
-- `service/authorization.py` (new) ā implement `admin_required` decorator.
-- `proposal_route.py` ā add `@admin_required` to `approve_proposal` (and any admin actions).
-- `analysis_route.py` ā add `@admin_required` to approve/delete actions.
-- `index.html` and relevant templates ā show admin links conditionally (use `current_user.is_admin`).
-- `scripts/create_admin.py` (new) ā CLI to create admin user through `AuthService.register_user(..., role='admin')`.
-- `tests/integration/test_auth_admin_flow.py` (new) ā integration tests for admin flows.
-
-## Minimal code examples
-### User.is_admin property (add to user.py):
+Add server-side role-based authorization (user vs admin). Use the existing `role` column on `User`, add a convenient `is_admin` helper and an `admin_required` decorator, protect admin actions (approve/reject, deletes, management) at the controller level, seed an initial admin account via a small CLI helper, update templates to show admin UI elements, and add integration tests to prevent regressions. Keep changes minimal, well-tested and auditable.
+
+## Goals
+- Enforce admin-only operations server-side (not just hidden UI elements).
+- Provide a simple, auditable mechanism to create an initial admin locally.
+- Keep the role model simple (string role) with easy migration path to more granular RBAC later.
+- Add tests exercising admin vs normal user behavior.
+
+## Non-Goals
+- Complex RBAC (permissions table) ā defer to later if needed.
+- UI redesign ā add minimal UI changes only (navbar badges/links).
+
+## Implementation Steps (main points)
+1. Branch:
+ - `git checkout -b feat/auth-roles`
+1. Domain: `model/entity/user.py`
+ - Add property:
+ ```python
+ @property
+ def is_admin(self) -> bool:
+ return (self.role or '').lower() == 'admin'
+ ```
+1. Domain `model/entity/stream_analysis` and `model/entity/proposal`:
+ - Add relation stream_analysis -> user in database, create migration
+ - Add relation proposal -> user in database, create migration
+ This wil be made adding created_by field to both tables
+
+1. Domain: `model/repository/proposal_repository.py` and `model/repository/stream_analysis_repository.py`
+ add creator-aware query helpers and set new field `created_by` when creating analyses/proposals
+
+1. Authorization helper: new `service/authorization.py`
+ - Add `admin_required` decorator:
+ ```python
+ from functools import wraps
+ from flask import abort
+ from flask_login import current_user, login_required
+
+ def admin_required(func):
+ @wraps(func)
+ @login_required
+ def wrapper(*args, **kwargs):
+ if not getattr(current_user, "is_admin", False):
+ abort(403)
+ return func(*args, **kwargs)
+ return wrapper
+ ```
+1. Protect server routes (examples)
+ - `route/proposal_route.py`:
+ - Add `from service.authorization import admin_required`
+ - Annotate:
+ ```python
+ @proposal_bp.route('/approve/', methods=['POST'])
+ @admin_required
+ def approve_proposal(proposal_id):
+ ...
+ ```
+ - `route/analysis_route.py`:
+ - Protect `approve_analysis` and `delete_analysis`.
+1. Templates: show admin UI (examples)
+ - `templates/index.html` (navbar):
+ ```jinja
+ {% if current_user.is_authenticated and current_user.is_admin %}
+ Admin: Proposals
+ {% endif %}
+ ```
+ - Buttons for approve/delete: keep server enforcement and render only for admin:
+ ```jinja
+ {% if current_user.is_authenticated and current_user.is_admin %}
+
+ {% endif %}
+ ```
+1. Seed CLI (new) `scripts/create_admin.py` ā minimal safe script:
+ ```python
+ # scripts/create_admin.py
+ import argparse
+ from app import app
+ from service.auth_service import AuthService
+
+ def main():
+ p = argparse.ArgumentParser()
+ p.add_argument('--email', required=True)
+ p.add_argument('--password', required=True)
+ args = p.parse_args()
+
+ AuthService(app).register_user(email=args.email, password=args.password, role='admin')
+ print(f"Admin user '{args.email}' created.")
+
+ if __name__ == '__main__':
+ main()
+ ```
+ - Run locally (do not commit secrets): `python scripts/create_admin.py --email $ADMIN_EMAIL --password $ADMIN_PASSWORD`
+
+1. Tests (new)
+ In general use the existing test DB fixture in `tests/conftest.py` to create users with different roles and test the behavior of protected endpoints.
+
+1. Audit (optional)
+ - Consider a simple `admin_actions` table for future auditing. Optional for MVP. This will be made adding a new table with fields: id, admin_user_id, action_type, target_id, timestamp, in a future PR.
+
+## File Summary (main, for exact list see PR)
+- `model/entity/user.py` ā add `is_admin` property.
+- `service/authorization.py` ā new.
+- `route/proposal_route.py` ā import decorator + protect `approve_proposal`.
+- `route/analysis_route.py` ā protect `approve_analysis` and `delete_analysis`.
+- `templates/index.html`, `templates/proposals.html`, `templates/analysis.html` ā conditional UI.
+- `scripts/create_admin.py` ā new seed script.
+- `tests/integration/test_auth_admin_flow.py` ā new integration tests.
+
+## Example: Protecting Analysis endpoints
+In `route/analysis_route.py`:
```python
-@property
-def is_admin(self) -> bool:
- return (self.role or '').lower() == 'admin'
-```
+from service.authorization import admin_required
-### admin_required decorator (new service/authorization.py):
-```python
-from functools import wraps
-from flask import abort
-from flask_login import current_user, login_required
-
-def admin_required(func):
- @wraps(func)
- @login_required
- def wrapper(*args, **kwargs):
- if not getattr(current_user, "is_admin", False):
- abort(403)
- return func(*args, **kwargs)
- return wrapper
-```
+@analysis_bp.route('/approve/', methods=['POST'])
+@admin_required
+def approve_analysis(id: int):
+ ...
-### Protecting an endpoint:
-```python
-@proposal_bp.route('/approve/', methods=['POST'])
+@analysis_bp.route('/delete/', methods=['POST'])
@admin_required
-def approve_proposal(proposal_id):
+def delete_analysis(id: int):
...
```
-## Database / migration notes
-- No schema change required: role already exists on users.
-- If you want a DB-level constraint or an enum later, create a migration step in `migrate_db`.
-- Seeding admin can be done with a small script (preferred) rather than committing secrets in migrations.
+## Rationale of roles
+- User not authenticated: listen, browse sources, view proposals, but no user or admin actions.
+- Normal user (`role=user`): register/login, analyze streams, submit proposals, edit its own proposals, but no approve/reject, not edit7delete of radio sources.
+- Admin user (`role=admin`): all normal user actions + approve/reject proposals, delete analyses, manage radio sources.
-## Seed / CLI script (recommended)
-Create `scripts/create_admin.py` to be run locally/once:
-```bash
-python scripts/create_admin.py --email admin@example.com --password 'StrongPass!'
-```
+## Testing notes and expected behavior
+
+- Admin success: approve endpoint returns redirect and DB effect (proposal created / radio source saved).
+- Normal user: call returns HTTP 403 (Abort), or custom error page ā tests should expect 403.
+- Test fixtures: use `tests/conftest.py` test DB and call `AuthService.register_user(..., role='admin')` or create user directly via repository + hashed password.
-The script should:
-- Import app context or call `AuthService(app)` with test/production config.
-- Call `AuthService.register_user(email, password, role='admin')`.
-- Print the created user's id/email (do not echo password).
-
-## Tests to add
-`tests/integration/test_auth_admin_flow.py`:
-- Register a user with `role='admin'` using the service (not the public registration page) or the seed script in test.
-- Log in via test client.
-- POST to `/proposal/approve/` and assert redirect/status and DB effect (proposal converted to radio source).
-- Repeat same action as a normal user and assert 403 (or appropriate denial).
-- Add unit tests:
- - `User.is_admin` correctness.
- - `admin_required` returns 403 for non-admins.
-
-## UI changes
-- Navbar: show an "Admin" area or admin links if `current_user.is_admin`.
-- Buttons for approve/delete: continue to render only when `current_user.is_admin`, but server must enforce checks regardless.
-- Keep UX minimal ā a top-level "Admin" dropdown linking to panels for proposals/analysis is fine.
-
-## Security & behavior rules
-- Server-side enforcement always required ā do not rely on UI hiding.
-- Use `abort(403)` for unauthorized admin attempts (tests should expect 403).
-- Audit: consider adding a simple audit log for admin actions (table `admin_actions` with `user_id`, `action`, `target`, `timestamp`). Optional but recommended for production.
+## Migration & DB notes
+- No DB schema change required ā `role` exists.
+- If you later want DB-level constraints, add a migration in `migrate_db/`.
+
+## Security considerations
+- Do not expose admin UI as the only protection ā always enforce on server.
+- Seed script should be run locally and never commit credentials. Consider generating a one-time password printed to stdout and requiring rotation.
+- Consider adding logging for admin actions (timestamp, user id, action, target).
## Rollout checklist
-- Create feature branch `feat/auth-roles`.
-- Implement `is_admin` and `admin_required`.
-- Protect endpoints and add tests.
-- Add `scripts/create_admin.py` and run locally to create initial admin.
-- Update templates to surface admin UI.
-- Run full test suite: `pytest -q`.
-- Commit & push branch, open PR.
-- Manual smoke test: create admin, login, try approve/reject/delete flows; verify normal user cannot.
-- (Optional) Add migration if you later enforce DB-level constraints.
-
-## Commands & recommended workflow
-Create branch and run tests locally:
+1. Create branch `feat/auth-roles`.
+2. Implement code changes and tests.
+3. Run `pytest -q` locally, fix failures.
+4. Add `scripts/create_admin.py` and create an initial admin locally to test admin flows.
+5. Commit and push branch; open PR with description and test run output.
+
+## Commands & workflow
```bash
git checkout -b feat/auth-roles
# implement changes...
@@ -113,18 +161,15 @@ python -m pip install -r requirements.txt
pytest -q
```
-Create an admin locally using the seed script (after adding it):
-```bash
-python scripts/create_admin.py --email admin@local.test --password 'ReplaceMe!'
-```
-
## Estimated effort
- Basic implementation + tests: 2ā4 hours.
-- With audit logging + polished admin UI: 4ā8 hours.
-
-## Optional future enhancements
-- Granular RBAC (roles + permissions table).
-- Admin UI pages for user management.
-- Audit trail (DB table).
-- Rate-limits / throttling for admin endpoints.
-- Admin-only API keys and 2FA for admin accounts.
\ No newline at end of file
+- With audit logging + nicer admin UI: 4ā8 hours.
+
+## Optional extensions
+- Granular permissions table.
+- Admin user management UI.
+- 2FA for admin accounts.
+
+## Final notes
+- Keep changes small and test-first. Add the seed script to your local `scripts/` folder and do not check passwords into source control.
+- If you'd like, I can produce the skeleton files and patches for all file edits and the tests so you can apply them in one go.
\ No newline at end of file
diff --git a/templates/database.html b/templates/database.html
index 31f5487..f4070c4 100644
--- a/templates/database.html
+++ b/templates/database.html
@@ -12,8 +12,7 @@
@@ -69,12 +68,14 @@ Database Management
{{ rs.id }} |
{{ rs.name }} |
{{ rs.stream_url }} |
+ {% if current_user.is_authenticated and current_user.is_admin %}
|
+ {% endif %}
{% endfor %}
diff --git a/templates/index.html b/templates/index.html
index 7da9f87..7de0ca1 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -5,6 +5,18 @@
RadioChWeb - Home
+
@@ -13,10 +25,10 @@
RadioChWeb
Browse Sources
+ {% if current_user.is_authenticated %}
Analyze Stream
View Proposals
Database
- {% if current_user.is_authenticated %}
Signed in as {{ current_user.email }}
Change password
Logout
@@ -46,17 +58,29 @@
Welcome to RadioChWeb
{% for source in sources %}
-
+
-
{{ source.name }}
-
{{ source.description or 'No description available' }}
-
{{ source.website_url }}
-
View Details
-
-
Listen
-
+
{{ source.title }}
+
{{ source.description or "No description available" }}
+
+
+
+
+
+
+
+
+
{% endfor %}
diff --git a/templates/proposal_detail.html b/templates/proposal_detail.html
index f2e7497..1836500 100644
--- a/templates/proposal_detail.html
+++ b/templates/proposal_detail.html
@@ -69,18 +69,19 @@
Edit Proposal
-
+
-
+ {% if current_user.is_authenticated %}
+ {% endif %}
diff --git a/templates/proposals.html b/templates/proposals.html
index 0a66ff6..01ff349 100644
--- a/templates/proposals.html
+++ b/templates/proposals.html
@@ -49,11 +49,15 @@ Pending Proposals
diff --git a/templates/source_detail.html b/templates/source_detail.html
index 21bc75c..7e9345d 100644
--- a/templates/source_detail.html
+++ b/templates/source_detail.html
@@ -38,11 +38,13 @@ {{ source.name }}
Listen
+ {% if current_user.is_authenticated and current_user.is_admin %}
Edit
+ {% endif %}
diff --git a/test-continue.md b/test-continue.md
new file mode 100644
index 0000000..05304a1
--- /dev/null
+++ b/test-continue.md
@@ -0,0 +1,74 @@
+Session summary ā 2025-12-05
+
+Workspace / branch
+- Repo: RadioChWeb
+- Branch: feat/auth-layer-apply-roles
+- CWD: /home/riccardo/Documenti/Programming/Projects/RadioChWeb
+
+What I changed (already committed in this branch)
+- Database migration V6 (was created then reverted/undone by user; verify file contents before running migrations).
+- Added `created_by` column in model entities:
+ - `model/entity/proposal.py`: `created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)` and relationship `proposal_user`.
+ - `model/entity/stream_analysis.py`: `created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)` and relationship `stream_user`.
+- Repository updates:
+ - `model/repository/proposal_repository.py`: added `find_by_creator` & `find_by_created_by` helpers.
+ - `model/repository/stream_analysis_repository.py`: added `find_by_creator` & `find_by_created_by` helpers.
+- Route/service changes:
+ - `route/analysis_route.py`: set `created_by=current_user.id` when saving analyses and pass `created_by` when approving.
+ - `service/stream_analysis_service.py`: `save_analysis_as_proposal` accepts `created_by` arg and sets it on created `Proposal`.
+- Tests/fixtures:
+ - `tests/conftest.py`: provides `test_user`, `admin_user`, `login_helper`, and `login_admin_helper` fixtures (login helpers set session keys).
+ - Several tests updated/inspected; some test fixes suggested (see failures below).
+
+Test runs
+- Command used: `python3 -m pytest -q tests/`
+- Latest result snapshot (summary lines):
+ - Total collected: 53 tests
+ - Final: 51 passed, 2 failed
+ - Failing tests:
+ 1. tests/integration/test_smoke_auth_pages.py::test_smoke_auth_pages_render
+ - Error: Flask AssertionError: "The setup method 'after_request' can no longer be called..."
+ - Root cause: `AuthService.init_app(app)` / `LoginManager.init_app(app)` was called after the app handled a request; must initialize extensions before the first request (initialize in `test_app` fixture / app factory).
+ 2. tests/unit/test_proposal_update.py::test_update_proposal_post
+ - Symptom: `image_url` did not update (assertion failure).
+ - Root cause: test created a `Proposal` with `proposal_user=client` (FlaskClient) instead of a `User` instance (e.g., `test_user`). Also test used `test_request_context` rather than posting through a logged-in `test_client`. Fix: assign `proposal_user=test_user` and use `client.post(...)` after `login_helper(client)`.
+
+Earlier run (before some fixes) showed 9 failures caused by NOT NULL `created_by` errors ā these were addressed by aligning repository/service/route code to pass `created_by` from `current_user`. Keep an eye for any remaining places where tests or code create objects directly without `created_by`.
+
+Pending TODOs (high-level)
+- Fix model-related test gaps (ensure tests create entities with `created_by` and services set created_by).
+- Initialize auth-related extensions in `test_app` fixture to avoid after-request setup errors.
+- After applying fixes, re-run full test suite until green.
+- Optionally: add `is_admin` property and `admin_required` decorator (spec in `specs/feature/feat-auth-layer-apply-roles.md`) and add integration tests for admin flows.
+
+Exact commands to save this summary and to re-run tests locally
+- Save summary (run once):
+ mkdir -p docs
+ cat > docs/session-2025-12-05-summary.md <<'EOF'
+ [paste the same summary text into STDIN if not using the above heredoc]
+ EOF
+
+- Re-run the full test suite (recommended flow):
+ python3 -m venv .venv
+ source .venv/bin/activate
+ python -m pip install --upgrade pip setuptools wheel
+ python -m pip install -r requirements.txt
+ python3 -m pytest -q tests/
+
+Notes & quick fixes to apply next session
+- In `tests/unit/test_proposal_update.py`:
+ - Add `test_user` fixture to test signature.
+ - Create the Proposal with `proposal_user=test_user` (not `client`).
+ - Use `with test_app.test_client() as client: login_helper(client); client.post(...)` to perform the update POST.
+- In `tests/conftest.py`:
+ - Ensure `test_user` and `admin_user` are committed (so `.id` exists) before using `login_helper`.
+ - Ensure `AuthService(app)` (LoginManager init) is performed in the `test_app` fixture before any test_client usage.
+- Before running schema migrations on your dev DB, back up the `instance/radio_sources.db` file.
+
+If you want, next session I can:
+- Apply the small test fixes (update `test_proposal_update.py`, ensure `test_app` initializes `AuthService`) and re-run tests.
+- Create a small backfill script or a migration plan for existing `created_by` values.
+
+Fix test_proposal_update.py to use test_user and post via client.post(...).
+Initialize AuthService(app) inside the test_app fixture before any requests.
+Re-run tests: python3 -m pytest -q tests/ and address any remaining failures.
\ No newline at end of file
diff --git a/test-output.txt b/test-output.txt
deleted file mode 100644
index 63e903e..0000000
--- a/test-output.txt
+++ /dev/null
@@ -1,157 +0,0 @@
-============================= 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 FAILED [ 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 PASSED [ 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_register_login_logout_flow ________________________
-
-test_app =
-
- def test_register_login_logout_flow(test_app):
- register_blueprints_and_auth(test_app)
- client = test_app.test_client()
-
- # Register
-> resp = client.post('/auth/register', data={
- 'email': 'tester@example.com',
- 'password': 'Password123',
- 'confirm': 'Password123'
- }, follow_redirects=True)
-
-tests/integration/test_auth_flow.py:29:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-.venv/lib64/python3.14/site-packages/werkzeug/test.py:1167: in post
- return self.open(*args, **kw)
-.venv/lib64/python3.14/site-packages/flask/testing.py:232: in open
- response = super().open(
-.venv/lib64/python3.14/site-packages/werkzeug/test.py:1150: in open
- response = self.resolve_redirect(response, buffered=buffered)
-.venv/lib64/python3.14/site-packages/werkzeug/test.py:1056: in resolve_redirect
- return self.open(builder, buffered=buffered)
-.venv/lib64/python3.14/site-packages/flask/testing.py:232: in open
- response = super().open(
-.venv/lib64/python3.14/site-packages/werkzeug/test.py:1116: in open
- response_parts = self.run_wsgi_app(request.environ, buffered=buffered)
-.venv/lib64/python3.14/site-packages/werkzeug/test.py:988: in run_wsgi_app
- rv = run_wsgi_app(self.application, environ, buffered=buffered)
-.venv/lib64/python3.14/site-packages/werkzeug/test.py:1264: in run_wsgi_app
- app_rv = app(environ, start_response)
-.venv/lib64/python3.14/site-packages/flask/app.py:1478: in __call__
- return self.wsgi_app(environ, start_response)
-.venv/lib64/python3.14/site-packages/flask/app.py:1458: in wsgi_app
- response = self.handle_exception(e)
-.venv/lib64/python3.14/site-packages/flask/app.py:1455: in wsgi_app
- response = self.full_dispatch_request()
-.venv/lib64/python3.14/site-packages/flask/app.py:869: in full_dispatch_request
- rv = self.handle_user_exception(e)
-.venv/lib64/python3.14/site-packages/flask/app.py:867: in full_dispatch_request
- rv = self.dispatch_request()
-.venv/lib64/python3.14/site-packages/flask/app.py:852: in dispatch_request
- return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
- @auth_bp.route('/login', methods=['GET', 'POST'])
- def login():
-> form = LoginForm()
-E NameError: name 'LoginForm' is not defined
-
-route/auth_route.py:58: NameError
-_________________________ test_smoke_auth_pages_render _________________________
-
-test_app =
-
- def test_smoke_auth_pages_render(test_app):
-> register_blueprints(test_app)
-
-tests/integration/test_smoke_auth_pages.py:25:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-tests/integration/test_smoke_auth_pages.py:22: 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 = , 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
-=========================== short test summary info ============================
-FAILED tests/integration/test_auth_flow.py::test_register_login_logout_flow
-FAILED tests/integration/test_smoke_auth_pages.py::test_smoke_auth_pages_render
-========================= 2 failed, 51 passed in 0.59s =========================
diff --git a/tests/conftest.py b/tests/conftest.py
index 4b2f4ac..9fecd54 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,20 +11,25 @@
# Add current directory to Python path for tests
sys.path.insert(0, str(Path(__file__).parent.parent))
+# to make tables found at db.create_all()
from database import db
from model.entity.stream_type import StreamType
+
+
@pytest.fixture(scope="session")
def test_app():
+
+
"""Create a test Flask application."""
app = Flask(__name__)
- app.config['TESTING'] = True
- app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
- app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+ app.config["TESTING"] = True
+ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# Required for session/flash in tests
- app.secret_key = 'test-secret'
+ app.secret_key = "test-secret"
# Disable CSRF in tests for simplicity (we assert behavior, not CSRF infra)
- app.config['WTF_CSRF_ENABLED'] = False
+ app.config["WTF_CSRF_ENABLED"] = False
db.init_app(app)
@@ -34,6 +39,7 @@ def test_app():
_initialize_stream_types()
# ensure LoginManager is configured early
from service.auth_service import AuthService
+
AuthService(app)
yield app
@@ -65,33 +71,161 @@ def sample_urls():
"valid_http_aac": "http://stream.example.com:8000/stream.aac",
"invalid_html": "https://example.com/index.html",
"rtmp_unsupported": "rtmp://stream.example.com/live",
- "hls_playlist": "https://stream.example.com/playlist.m3u8"
+ "hls_playlist": "https://stream.example.com/playlist.m3u8",
}
+@pytest.fixture
+def test_user(test_db):
+ """Create (or return existing) a test user with role 'user'."""
+ from model.entity.user import User
+
+ existing = test_db.query(User).filter_by(email="testuser@example.com").first()
+ if existing:
+ return existing
+
+ user = User(
+ email="testuser@example.com",
+ hash_password="hashed_password1",
+ role="user",
+ )
+ test_db.add(user)
+ test_db.flush() # assign id without committing
+ return user
+
+
+@pytest.fixture
+def admin_user(test_db):
+ """Create (or return existing) an admin user with role 'admin'."""
+ from model.entity.user import User
+
+ existing = test_db.query(User).filter_by(email="adminuser@example.com").first()
+ if existing:
+ return existing
+
+ user = User(
+ email="adminuser@example.com",
+ hash_password="hashed_password2",
+ role="admin",
+ )
+ test_db.add(user)
+ test_db.flush()
+ return user
+
+
+# role="user" login helper
+@pytest.fixture
+def login_helper(test_app, test_user):
+ """Return a helper that logs test_user into a test_client by setting session keys."""
+ def login(client):
+ # client is a Flask test_client instance
+ with client.session_transaction() as sess:
+ sess['_user_id'] = str(test_user.id)
+ sess['_fresh'] = True
+ return login
+
+# role="admin" login helper
+@pytest.fixture
+def login_admin_helper(test_app, admin_user):
+ """Return a helper that logs admin_user into a test_client by setting session keys."""
+ def login(client):
+ with client.session_transaction() as sess:
+ sess['_user_id'] = str(admin_user.id)
+ sess['_fresh'] = True
+ return login
def _initialize_stream_types():
"""Initialize stream types in test database."""
# Ensure related entity classes are imported so SQLAlchemy can configure relationships
stream_types_data = [
- {"protocol": "HTTP", "format": "MP3", "metadata_type": "Icecast", "display_name": "HTTP MP3 Icecast"},
- {"protocol": "HTTP", "format": "MP3", "metadata_type": "Shoutcast", "display_name": "HTTP MP3 Shoutcast"},
- {"protocol": "HTTP", "format": "AAC", "metadata_type": "Icecast", "display_name": "HTTP AAC Icecast"},
- {"protocol": "HTTP", "format": "AAC", "metadata_type": "Shoutcast", "display_name": "HTTP AAC Shoutcast"},
- {"protocol": "HTTPS", "format": "MP3", "metadata_type": "Icecast", "display_name": "HTTPS MP3 Icecast"},
- {"protocol": "HTTPS", "format": "MP3", "metadata_type": "Shoutcast", "display_name": "HTTPS MP3 Shoutcast"},
- {"protocol": "HTTPS", "format": "AAC", "metadata_type": "Icecast", "display_name": "HTTPS AAC Icecast"},
- {"protocol": "HTTPS", "format": "AAC", "metadata_type": "Shoutcast", "display_name": "HTTPS AAC Shoutcast"},
- {"protocol": "HLS", "format": "AAC", "metadata_type": "None", "display_name": "HLS AAC"},
- {"protocol": "HLS", "format": "MP3", "metadata_type": "None", "display_name": "HLS MP3"},
- {"protocol": "HTTP", "format": "OGG", "metadata_type": "Icecast", "display_name": "HTTP OGG Icecast"},
- {"protocol": "HTTPS", "format": "OGG", "metadata_type": "Icecast", "display_name": "HTTPS OGG Icecast"},
- {"protocol": "HTTP", "format": "FLAC", "metadata_type": "None", "display_name": "HTTP FLAC"},
- {"protocol": "HTTPS", "format": "FLAC", "metadata_type": "None", "display_name": "HTTPS FLAC"},
+ {
+ "protocol": "HTTP",
+ "format": "MP3",
+ "metadata_type": "Icecast",
+ "display_name": "HTTP MP3 Icecast",
+ },
+ {
+ "protocol": "HTTP",
+ "format": "MP3",
+ "metadata_type": "Shoutcast",
+ "display_name": "HTTP MP3 Shoutcast",
+ },
+ {
+ "protocol": "HTTP",
+ "format": "AAC",
+ "metadata_type": "Icecast",
+ "display_name": "HTTP AAC Icecast",
+ },
+ {
+ "protocol": "HTTP",
+ "format": "AAC",
+ "metadata_type": "Shoutcast",
+ "display_name": "HTTP AAC Shoutcast",
+ },
+ {
+ "protocol": "HTTPS",
+ "format": "MP3",
+ "metadata_type": "Icecast",
+ "display_name": "HTTPS MP3 Icecast",
+ },
+ {
+ "protocol": "HTTPS",
+ "format": "MP3",
+ "metadata_type": "Shoutcast",
+ "display_name": "HTTPS MP3 Shoutcast",
+ },
+ {
+ "protocol": "HTTPS",
+ "format": "AAC",
+ "metadata_type": "Icecast",
+ "display_name": "HTTPS AAC Icecast",
+ },
+ {
+ "protocol": "HTTPS",
+ "format": "AAC",
+ "metadata_type": "Shoutcast",
+ "display_name": "HTTPS AAC Shoutcast",
+ },
+ {
+ "protocol": "HLS",
+ "format": "AAC",
+ "metadata_type": "None",
+ "display_name": "HLS AAC",
+ },
+ {
+ "protocol": "HLS",
+ "format": "MP3",
+ "metadata_type": "None",
+ "display_name": "HLS MP3",
+ },
+ {
+ "protocol": "HTTP",
+ "format": "OGG",
+ "metadata_type": "Icecast",
+ "display_name": "HTTP OGG Icecast",
+ },
+ {
+ "protocol": "HTTPS",
+ "format": "OGG",
+ "metadata_type": "Icecast",
+ "display_name": "HTTPS OGG Icecast",
+ },
+ {
+ "protocol": "HTTP",
+ "format": "FLAC",
+ "metadata_type": "None",
+ "display_name": "HTTP FLAC",
+ },
+ {
+ "protocol": "HTTPS",
+ "format": "FLAC",
+ "metadata_type": "None",
+ "display_name": "HTTPS FLAC",
+ },
]
for st_data in stream_types_data:
stream_type = StreamType(**st_data)
db.session.add(stream_type)
- db.session.commit()
\ No newline at end of file
+ db.session.commit()
diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py
index fb3dbf5..2ccc24f 100644
--- a/tests/integration/test_auth_flow.py
+++ b/tests/integration/test_auth_flow.py
@@ -11,37 +11,49 @@
def register_blueprints_and_auth(app):
# Ensure Flask finds the repository `templates/` folder when tests create app instances
- app.template_folder = str(Path(__file__).parents[2] / 'templates')
+ app.template_folder = str(Path(__file__).parents[2] / "templates")
# Register blueprints only if they are not already registered (idempotent)
- for bp in (main_bp, database_bp, analysis_bp, proposal_bp, radio_source_bp, listen_bp, auth_bp):
+ for bp in (
+ main_bp,
+ database_bp,
+ analysis_bp,
+ proposal_bp,
+ radio_source_bp,
+ listen_bp,
+ auth_bp,
+ ):
if bp.name not in app.blueprints:
app.register_blueprint(bp)
if not hasattr(app, "login_manager"):
AuthService(app)
-
def test_register_login_logout_flow(test_app):
register_blueprints_and_auth(test_app)
client = test_app.test_client()
# Register
- resp = client.post('/auth/register', data={
- 'email': 'tester@example.com',
- 'password': 'Password123',
- 'confirm': 'Password123'
- }, follow_redirects=True)
- assert b'Registration successful' in resp.data
+ resp = client.post(
+ "/auth/register",
+ data={
+ "email": "tester@example.com",
+ "password": "Password123",
+ "confirm": "Password123",
+ },
+ follow_redirects=True,
+ )
+ assert b"Registration successful" in resp.data
# Login
- resp = client.post('/auth/login', data={
- 'email': 'tester@example.com',
- 'password': 'Password123'
- }, follow_redirects=True)
+ resp = client.post(
+ "/auth/login",
+ data={"email": "tester@example.com", "password": "Password123"},
+ follow_redirects=True,
+ )
# After login index should show Logout link and email
- assert b'Logout' in resp.data
- assert b'tester@example.com' in resp.data
+ assert b"Logout" in resp.data
+ assert b"tester@example.com" in resp.data
# Logout
- resp = client.get('/auth/logout', follow_redirects=True)
- assert b'Login' in resp.data
+ resp = client.get("/auth/logout", follow_redirects=True)
+ assert b"Login" in resp.data
diff --git a/tests/integration/test_smoke_auth_pages.py b/tests/integration/test_smoke_auth_pages.py
index 7c2e37a..a4fc48b 100644
--- a/tests/integration/test_smoke_auth_pages.py
+++ b/tests/integration/test_smoke_auth_pages.py
@@ -10,11 +10,18 @@ def register_blueprints(app):
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.template_folder = str(Path(__file__).parents[2] / 'templates')
+ app.template_folder = str(Path(__file__).parents[2] / "templates")
# Register blueprints only if they are not already registered (idempotent)
- for bp in (main_bp, database_bp, analysis_bp, proposal_bp, radio_source_bp, listen_bp, auth_bp):
+ for bp in (
+ main_bp,
+ database_bp,
+ analysis_bp,
+ proposal_bp,
+ radio_source_bp,
+ listen_bp,
+ auth_bp,
+ ):
if bp.name not in app.blueprints:
app.register_blueprint(bp)
@@ -23,10 +30,10 @@ def test_smoke_auth_pages_render(test_app):
register_blueprints(test_app)
client = test_app.test_client()
- r = client.get('/auth/login')
+ r = client.get("/auth/login")
assert r.status_code == 200
- assert b'Login' in r.data
+ assert b"Login" in r.data
- r = client.get('/auth/register')
+ r = client.get("/auth/register")
assert r.status_code == 200
- assert b'Register' in r.data
+ assert b"Register" in r.data
diff --git a/tests/integration/test_validate_and_add_workflow.py b/tests/integration/test_validate_and_add_workflow.py
index 075fa82..694fdc8 100644
--- a/tests/integration/test_validate_and_add_workflow.py
+++ b/tests/integration/test_validate_and_add_workflow.py
@@ -12,7 +12,6 @@
import pytest
from model.entity.proposal import Proposal
from model.entity.radio_source import RadioSource
-from model.entity.stream_type import StreamType
from model.repository.proposal_repository import ProposalRepository
from model.repository.radio_source_repository import RadioSourceRepository
from service.proposal_validation_service import ProposalValidationService
@@ -23,16 +22,12 @@
class TestValidateAndAddWorkflow:
"""Integration tests for the complete validate and add workflow."""
- @pytest.fixture(scope="function")
- def db_session(self, test_db):
- """Use the test database session from conftest."""
- return test_db
@pytest.fixture
- def repositories(self, db_session):
+ def repositories(self, test_db):
"""Create repositories with test session."""
- proposal_repo = ProposalRepository(db_session)
- radio_source_repo = RadioSourceRepository(db_session)
+ proposal_repo = ProposalRepository(test_db)
+ radio_source_repo = RadioSourceRepository(test_db)
return proposal_repo, radio_source_repo
@pytest.fixture
@@ -40,10 +35,13 @@ def services(self, repositories):
"""Create services with repositories."""
proposal_repo, radio_source_repo = repositories
validation_service = ProposalValidationService(proposal_repo, radio_source_repo)
- radio_source_service = RadioSourceService(proposal_repo, radio_source_repo, validation_service)
+ radio_source_service = RadioSourceService(
+ proposal_repo, radio_source_repo, validation_service
+ )
return validation_service, radio_source_service
- def test_complete_save_workflow(self, db_session, services):
+
+ def test_complete_save_workflow(self, test_db, test_user, services):
"""Test the complete workflow: create proposal -> validate -> save as radio source."""
validation_service, radio_source_service = services
@@ -56,10 +54,11 @@ def test_complete_save_workflow(self, db_session, services):
is_secure=True,
country="Italy",
description="A test radio station",
- image_url="test.jpg"
+ image_url="test.jpg",
+ proposal_user=test_user
)
- db_session.add(proposal)
- db_session.commit()
+ test_db.add(proposal)
+ test_db.flush()
# Validate the proposal
validation_result = validation_service.validate_proposal(proposal.id)
@@ -71,16 +70,18 @@ def test_complete_save_workflow(self, db_session, services):
assert save_result.stream_url == "https://stream.example.com/radio.mp3"
# Verify proposal was deleted and radio source was created
- saved_proposal = db_session.query(Proposal).filter_by(id=proposal.id).first()
+ saved_proposal = test_db.query(Proposal).filter_by(id=proposal.id).first()
assert saved_proposal is None
- saved_radio_source = db_session.query(RadioSource).filter_by(
- stream_url="https://stream.example.com/radio.mp3"
- ).first()
+ saved_radio_source = (
+ test_db.query(RadioSource)
+ .filter_by(stream_url="https://stream.example.com/radio.mp3")
+ .first()
+ )
assert saved_radio_source is not None
assert saved_radio_source.name == "Test Radio Station"
- def test_duplicate_stream_url_prevention(self, db_session, services):
+ def test_duplicate_stream_url_prevention(self, test_db, test_user, services):
"""Test that duplicate stream URLs are prevented."""
validation_service, radio_source_service = services
@@ -90,10 +91,11 @@ def test_duplicate_stream_url_prevention(self, db_session, services):
name="First Radio",
website_url="https://first.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
+ proposal_user=test_user
)
- db_session.add(proposal1)
- db_session.commit()
+ test_db.add(proposal1)
+ test_db.flush()
# Save first proposal
save_result1 = radio_source_service.save_from_proposal(proposal1.id)
@@ -105,40 +107,46 @@ def test_duplicate_stream_url_prevention(self, db_session, services):
name="Second Radio",
website_url="https://second.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
+ proposal_user=test_user
)
- db_session.add(proposal2)
- db_session.commit()
+ test_db.add(proposal2)
+
+ test_db.flush()
# Validation should fail due to duplicate
validation_result = validation_service.validate_proposal(proposal2.id)
assert not validation_result.is_valid
- assert "This stream URL already exists in the database" in validation_result.errors
+ assert (
+ "This stream URL already exists in the database" in validation_result.errors
+ )
- def test_proposal_rejection_workflow(self, db_session, services):
+ def test_proposal_rejection_workflow(self, test_db, test_user, services):
"""Test rejecting a proposal."""
_, radio_source_service = services
-
+
# Create a proposal
proposal = Proposal(
stream_url="https://reject.example.com/stream.mp3",
name="Rejected Radio",
website_url="https://reject.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
+ proposal_user=test_user
)
- db_session.add(proposal)
- db_session.commit()
+ test_db.add(proposal)
+ test_db.flush()
# Reject the proposal
reject_result = radio_source_service.reject_proposal(proposal.id)
assert reject_result is True
# Verify proposal was deleted
- deleted_proposal = db_session.query(Proposal).filter_by(id=proposal.id).first()
+ deleted_proposal = test_db.query(Proposal).filter_by(id=proposal.id).first()
assert deleted_proposal is None
- def test_proposal_update_workflow(self, db_session, services):
+
+ def test_proposal_update_workflow(self, test_db, test_user, services):
"""Test updating proposal data."""
_, radio_source_service = services
@@ -150,30 +158,34 @@ def test_proposal_update_workflow(self, db_session, services):
stream_type_id=1,
is_secure=True,
country="Original Country",
- description="Original description"
+ description="Original description",
+ proposal_user=test_user
)
- db_session.add(proposal)
- db_session.commit()
+ test_db.add(proposal)
+ test_db.flush()
# Update the proposal
update_request = ProposalUpdateRequest(
name="Updated Name",
website_url="https://updated.com",
country="Updated Country",
- description="Updated description"
+ description="Updated description",
)
- update_result = radio_source_service.update_proposal(proposal.id, update_request)
+ update_result = radio_source_service.update_proposal(
+ proposal.id, update_request
+ )
assert update_result.name == "Updated Name"
# Verify the update
- updated_proposal = db_session.query(Proposal).filter_by(id=proposal.id).first()
+ updated_proposal = test_db.query(Proposal).filter_by(id=proposal.id).first()
assert updated_proposal.name == "Updated Name"
assert updated_proposal.website_url == "https://updated.com"
assert updated_proposal.country == "Updated Country"
assert updated_proposal.description == "Updated description"
- def test_validation_with_missing_required_fields(self, db_session, services):
+
+ def test_validation_with_missing_required_fields(self, test_db, test_user, services):
"""Test validation fails with missing required fields."""
validation_service, _ = services
@@ -183,17 +195,19 @@ def test_validation_with_missing_required_fields(self, db_session, services):
name="Test Radio",
website_url="https://test.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
+ proposal_user=test_user
)
- db_session.add(proposal)
- db_session.commit()
+ test_db.add(proposal)
+ test_db.flush()
# Validation should fail
validation_result = validation_service.validate_proposal(proposal.id)
assert not validation_result.is_valid
assert "Stream URL is required and cannot be empty" in validation_result.errors
- def test_insecure_stream_warning(self, db_session, services):
+
+ def test_insecure_stream_warning(self, test_db, test_user, services):
"""Test warning for insecure HTTP streams."""
validation_service, _ = services
@@ -203,10 +217,11 @@ def test_insecure_stream_warning(self, db_session, services):
name="Insecure Radio",
website_url="https://insecure.com",
stream_type_id=1,
- is_secure=False
+ is_secure=False,
+ proposal_user=test_user
)
- db_session.add(proposal)
- db_session.commit()
+ test_db.add(proposal)
+ test_db.flush()
# Validation should succeed but with warning
validation_result = validation_service.validate_proposal(proposal.id)
diff --git a/tests/unit/test_analysis_routes.py b/tests/unit/test_analysis_routes.py
index 4e8dfd6..1d10f7b 100644
--- a/tests/unit/test_analysis_routes.py
+++ b/tests/unit/test_analysis_routes.py
@@ -1,64 +1,93 @@
-import pytest
from unittest.mock import patch
-from route.analysis_route import analysis_bp, delete_analysis, approve_analysis
-from route.proposal_route import proposal_bp
-from database import db
from model.entity.stream_analysis import StreamAnalysis
from model.entity.proposal import Proposal
-def _register_blueprints(app):
- app.register_blueprint(analysis_bp, url_prefix='/analysis')
- app.register_blueprint(proposal_bp, url_prefix='/proposal')
-
-def test_delete_analysis_route_removes_row(test_app, test_db):
+def test_delete_analysis_route_removes_row(test_app, test_db, test_user, login_helper):
# Ensure session works for flashing
- test_app.secret_key = 'test-secret'
+ test_app.secret_key = "test-secret"
- # Insert a StreamAnalysis row
- sa = StreamAnalysis(stream_url='http://test/delete', is_valid=True, is_secure=False, stream_type_id=1)
+ # Insert a StreamAnalysis row with required fields
+ sa = StreamAnalysis(
+ stream_url="http://test/delete",
+ is_valid=True,
+ is_secure=False,
+ stream_type_id=1,
+ stream_user=test_user # a user can delete analyze that have created
+ )
test_db.add(sa)
test_db.commit()
-
assert sa.id is not None
+
+ # Ensure blueprint/extension registration happens before opening the client
+ from route.analysis_route import analysis_bp
+ if analysis_bp.name not in test_app.blueprints:
+ test_app.register_blueprint(analysis_bp)
- # Patch shutil.which so constructing StreamAnalysisService inside route doesn't raise
- with patch('service.stream_analysis_service.shutil.which', return_value='/usr/bin/ffmpeg'):
- # Patch url_for used inside the view to avoid needing registered endpoints
- with patch('route.analysis_route.url_for', return_value='/'):
- with test_app.test_request_context(f'/analysis/delete/{sa.id}', method='POST'):
- resp = delete_analysis(sa.id)
- # view returns a redirect response object (or response)
- assert resp is not None
+
+# Patch shutil.which so constructing StreamAnalysisService inside route doesn't raise
+ with patch(
+ "service.stream_analysis_service.shutil.which", return_value="/usr/bin/ffmpeg"
+ ):
+
+ with test_app.test_client() as client:
+ login_helper(client) # Log in as test_user
+ resp = client.post(f"/analysis/delete/{sa.id}")
+ print(resp.data)
+
+ # view returns a redirect response object (or response)
+ assert resp is not None
# Verify the row was deleted
found = test_db.query(StreamAnalysis).filter(StreamAnalysis.id == sa.id).first()
assert found is None
-def test_approve_analysis_route_creates_proposal(test_app, test_db):
+def test_approve_analysis_route_creates_proposal(test_app, test_db, login_helper, test_user):
# Ensure session works for flashing
- test_app.secret_key = 'test-secret'
+ test_app.secret_key = "test-secret"
# Insert a StreamAnalysis row with required fields
- sa = StreamAnalysis(stream_url='http://test/propose', is_valid=True, is_secure=True, stream_type_id=1)
+ sa = StreamAnalysis(
+ stream_url="http://test/propose",
+ is_valid=True,
+ is_secure=True,
+ stream_type_id=1,
+ stream_user=test_user
+ )
+
test_db.add(sa)
test_db.commit()
assert sa.id is not None
+
+ # Ensure blueprint/extension registration happens before opening the client
+ from route.analysis_route import analysis_bp
+ if analysis_bp.name not in test_app.blueprints:
+ test_app.register_blueprint(analysis_bp)
- with patch('service.stream_analysis_service.shutil.which', return_value='/usr/bin/ffmpeg'):
- with patch('route.analysis_route.url_for', return_value='/'):
- with test_app.test_request_context(f'/analysis/approve/{sa.id}', method='POST'):
- resp = approve_analysis(sa.id)
+ # Patch shutil.which so constructing StreamAnalysisService inside route doesn't raise
+ with patch(
+ "service.stream_analysis_service.shutil.which", return_value="/usr/bin/ffmpeg"
+ ):
+ with patch("route.analysis_route.url_for", return_value="/"):
+ with test_app.test_client() as client:
+ login_helper(client) # Log in as test_user
+ resp = client.post(f"/analysis/approve/{sa.id}")
assert resp is not None
# Verify the StreamAnalysis row was removed after proposing
- found = test_db.query(StreamAnalysis).filter(StreamAnalysis.stream_url == sa.stream_url).first()
+ found = (
+ test_db.query(StreamAnalysis)
+ .filter(StreamAnalysis.stream_url == sa.stream_url)
+ .first()
+ )
assert found is None
# Verify a Proposal was created for that stream_url
- proposal = test_db.query(Proposal).filter(Proposal.stream_url == sa.stream_url).first()
+ proposal = (
+ test_db.query(Proposal).filter(Proposal.stream_url == sa.stream_url).first()
+ )
assert proposal is not None
assert proposal.stream_url == sa.stream_url
diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py
index 81429bb..5cccbfd 100644
--- a/tests/unit/test_auth_service.py
+++ b/tests/unit/test_auth_service.py
@@ -3,7 +3,7 @@
def test_hash_and_verify_roundtrip():
svc = AuthService()
- plain = 'Secur3P@ss!'
+ plain = "Secur3P@ss!"
hashed = svc.hash_password(plain)
assert isinstance(hashed, str) and len(hashed) > 0
verified, new_hash = svc.verify_password(plain, hashed)
diff --git a/tests/unit/test_proposal_update.py b/tests/unit/test_proposal_update.py
index aa57a33..8a05335 100644
--- a/tests/unit/test_proposal_update.py
+++ b/tests/unit/test_proposal_update.py
@@ -1,52 +1,44 @@
from model.entity.proposal import Proposal
-def test_update_proposal_post(test_app, test_db):
- # Create a proposal in the test DB
+def test_update_proposal_post(test_app, test_db, test_user, login_helper):
+ # Create a proposal in DB
proposal = Proposal(
- stream_url='https://stream.example.com/test',
- name='Old Name',
- website_url='https://old.example.com',
+ 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'
+ country="OldCountry",
+ description="Old description",
+ image_url="https://old.example.com/img.png",
+ proposal_user=test_user,
)
test_db.add(proposal)
- test_db.commit()
+ test_db.flush()
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'
+ data: dict[str, str] = {
+ "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
+ # Ensure blueprint/extension registration happens before opening the client
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)
+ with test_app.test_client() as client:
+ # Use the login helper to set the session (_user_id etc.) for this client
+ login_helper(client)
- # Expect a redirect response to proposals index
+ # POST via the client so Flask-Login loads current_user
+ resp = client.post(f"/proposal/update/{proposal.id}", data=data, follow_redirects=False)
assert resp.status_code == 302
- # Reload from DB and assert changes
+ # Verify DB changes after the request
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'
+ assert updated.image_url == "https://new.example.com/img.png"
diff --git a/tests/unit/test_proposal_validation_service.py b/tests/unit/test_proposal_validation_service.py
index a36874e..34c07d5 100644
--- a/tests/unit/test_proposal_validation_service.py
+++ b/tests/unit/test_proposal_validation_service.py
@@ -21,23 +21,30 @@
from model.entity.stream_analysis import StreamAnalysis
from service.stream_analysis_service import StreamAnalysisService
+
@pytest.fixture
def mock_proposal_repo() -> ProposalRepository:
"""Create mock ProposalRepository."""
return Mock(spec=ProposalRepository)
+
@pytest.fixture
def mock_radio_source_repo() -> RadioSourceRepository:
"""Create mock RadioSourceRepository."""
return Mock(spec=RadioSourceRepository)
+
@pytest.fixture
def mock_stream_analysis_service() -> StreamAnalysisService:
"""Create mock StreamAnalysisService."""
return Mock(spec=StreamAnalysisService)
+
@pytest.fixture
-def validation_service(mock_proposal_repo: ProposalRepository, mock_radio_source_repo: RadioSourceRepository) -> ProposalValidationService:
+def validation_service(
+ mock_proposal_repo: ProposalRepository,
+ mock_radio_source_repo: RadioSourceRepository,
+) -> ProposalValidationService:
"""Create ProposalValidationService with mocked repositories."""
return ProposalValidationService(mock_proposal_repo, mock_radio_source_repo)
@@ -45,12 +52,19 @@ def validation_service(mock_proposal_repo: ProposalRepository, mock_radio_source
class TestProposalValidationService:
"""Test suite for ProposalValidationService."""
- def test_validate_proposal_success(self, validation_service: ProposalValidationService, mock_proposal_repo: ProposalRepository,
- mock_stream_analysis_service: StreamAnalysisService, mock_radio_source_repo: RadioSourceRepository) -> None:
+ def test_validate_proposal_success(
+ self,
+ validation_service: ProposalValidationService,
+ mock_proposal_repo: ProposalRepository,
+ mock_stream_analysis_service: StreamAnalysisService,
+ mock_radio_source_repo: RadioSourceRepository,
+ ) -> None:
"""Test validation succeeds for valid proposal."""
-
- url="https://stream.example.com/radio.mp3"
- mock_analysis_result: StreamAnalysisResult = mock_stream_analysis_service.analyze_stream(url).return_value
+
+ url = "https://stream.example.com/radio.mp3"
+ mock_analysis_result: StreamAnalysisResult = (
+ mock_stream_analysis_service.analyze_stream(url).return_value
+ )
mock_analysis_result.success = True
mock_analysis_result.content_type = "audio/mpeg"
mock_analysis_result.raw_output = "HTTP/1.1 200 OK\nContent-Type: audio/mpeg"
@@ -67,8 +81,9 @@ def test_validate_proposal_success(self, validation_service: ProposalValidationS
name="Test Radio",
website_url="http://example.com",
stream_type_id=mock_analysis_result.stream_type_id,
- is_secure=True)
-
+ is_secure=True,
+ )
+
mock_proposal_repo.find_by_id.return_value = proposal
mock_radio_source_repo.find_by_url.return_value = None # No duplicate
mock_stream_analysis_service.analyze_stream.return_value = mock_analysis_result
@@ -81,7 +96,11 @@ def test_validate_proposal_success(self, validation_service: ProposalValidationS
assert len(result.errors) == 0
assert len(result.warnings) == 0
- def test_validate_proposal_missing_required_fields(self, validation_service: ProposalValidationService, mock_proposal_repo: ProposalRepository):
+ def test_validate_proposal_missing_required_fields(
+ self,
+ validation_service: ProposalValidationService,
+ mock_proposal_repo: ProposalRepository,
+ ):
"""Test validation fails for missing required fields."""
# Arrange
proposal = Proposal(
@@ -90,7 +109,7 @@ def test_validate_proposal_missing_required_fields(self, validation_service: Pro
name="Test Radio",
website_url="https://example.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
)
mock_proposal_repo.find_by_id.return_value = proposal
@@ -101,7 +120,11 @@ def test_validate_proposal_missing_required_fields(self, validation_service: Pro
assert not result.is_valid
assert "Stream URL is required and cannot be empty" in result.errors
- def test_validate_proposal_invalid_url_format(self, validation_service: ProposalValidationService, mock_proposal_repo: ProposalRepository):
+ def test_validate_proposal_invalid_url_format(
+ self,
+ validation_service: ProposalValidationService,
+ mock_proposal_repo: ProposalRepository,
+ ):
"""Test validation fails for invalid URL format."""
# Arrange
proposal = Proposal(
@@ -110,7 +133,7 @@ def test_validate_proposal_invalid_url_format(self, validation_service: Proposal
name="Test Radio",
website_url="https://example.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
)
mock_proposal_repo.find_by_id.return_value = proposal
@@ -121,7 +144,12 @@ def test_validate_proposal_invalid_url_format(self, validation_service: Proposal
assert not result.is_valid
assert "Invalid stream URL format: not-a-valid-url" in result.errors
- def test_validate_proposal_duplicate_stream_url(self, validation_service: ProposalValidationService, mock_proposal_repo: ProposalRepository, mock_radio_source_repo: RadioSourceRepository):
+ def test_validate_proposal_duplicate_stream_url(
+ self,
+ validation_service: ProposalValidationService,
+ mock_proposal_repo: ProposalRepository,
+ mock_radio_source_repo: RadioSourceRepository,
+ ):
"""Test validation fails for duplicate stream URL."""
# Arrange
proposal = Proposal(
@@ -130,10 +158,17 @@ def test_validate_proposal_duplicate_stream_url(self, validation_service: Propos
name="Test Radio",
website_url="https://example.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
)
mock_proposal_repo.find_by_id.return_value = proposal
- mock_radio_source_repo.find_by_url.return_value = RadioSource(id=1, stream_url="https://stream.example.com/radio.mp3", name="Existing Radio", website_url="https://existing.com", stream_type_id=1, is_secure=True)
+ mock_radio_source_repo.find_by_url.return_value = RadioSource(
+ id=1,
+ stream_url="https://stream.example.com/radio.mp3",
+ name="Existing Radio",
+ website_url="https://existing.com",
+ stream_type_id=1,
+ is_secure=True,
+ )
# Act
result = validation_service.validate_proposal(1)
@@ -142,7 +177,12 @@ def test_validate_proposal_duplicate_stream_url(self, validation_service: Propos
assert not result.is_valid
assert "This stream URL already exists in the database" in result.errors
- def test_validate_proposal_insecure_stream_warning(self, validation_service: ProposalValidationService, mock_proposal_repo: ProposalRepository, mock_radio_source_repo: RadioSourceRepository):
+ def test_validate_proposal_insecure_stream_warning(
+ self,
+ validation_service: ProposalValidationService,
+ mock_proposal_repo: ProposalRepository,
+ mock_radio_source_repo: RadioSourceRepository,
+ ):
"""Test validation warns for insecure HTTP streams."""
# Arrange
proposal = Proposal(
@@ -151,7 +191,7 @@ def test_validate_proposal_insecure_stream_warning(self, validation_service: Pro
name="Insecure Radio",
website_url="https://insecure.com",
stream_type_id=1,
- is_secure=False
+ is_secure=False,
)
mock_proposal_repo.find_by_id.return_value = proposal
mock_radio_source_repo.find_by_url.return_value = None
@@ -163,7 +203,11 @@ def test_validate_proposal_insecure_stream_warning(self, validation_service: Pro
assert result.is_valid # Still valid, just a warning
assert "This stream uses HTTP (not secure)" in result.warnings
- def test_validate_proposal_nonexistent_proposal(self, validation_service: ProposalValidationService, mock_proposal_repo: ProposalRepository):
+ def test_validate_proposal_nonexistent_proposal(
+ self,
+ validation_service: ProposalValidationService,
+ mock_proposal_repo: ProposalRepository,
+ ):
"""Test validation fails for nonexistent proposal."""
# Arrange
mock_proposal_repo.find_by_id.return_value = None
@@ -175,46 +219,67 @@ def test_validate_proposal_nonexistent_proposal(self, validation_service: Propos
assert not result.is_valid
assert "Proposal with ID 999 not found" in result.errors
- def test_validate_url_format_valid_urls(self, validation_service: ProposalValidationService):
+ def test_validate_url_format_valid_urls(
+ self, validation_service: ProposalValidationService
+ ):
"""Test URL format validation for valid URLs."""
valid_urls = [
"https://stream.example.com/radio.mp3",
"http://stream.example.com:8000/stream.aac",
- "https://example.com/playlist.m3u8"
+ "https://example.com/playlist.m3u8",
]
for url in valid_urls:
assert validation_service._is_valid_url(url), f"URL should be valid: {url}"
- def test_validate_url_format_invalid_urls(self, validation_service: ProposalValidationService):
+ def test_validate_url_format_invalid_urls(
+ self, validation_service: ProposalValidationService
+ ):
"""Test URL format validation for invalid URLs."""
- invalid_urls = [
- "not-a-url",
- "rtmp://stream.example.com/live",
- ""
- ]
+ invalid_urls = ["not-a-url", "rtmp://stream.example.com/live", ""]
for url in invalid_urls:
- assert not validation_service._is_valid_url(url), f"URL should be invalid: {url}"
-
- def test_check_duplicate_stream_url_no_duplicate(self, validation_service: ProposalValidationService, mock_radio_source_repo: RadioSourceRepository):
+ assert not validation_service._is_valid_url(
+ url
+ ), f"URL should be invalid: {url}"
+
+ def test_check_duplicate_stream_url_no_duplicate(
+ self,
+ validation_service: ProposalValidationService,
+ mock_radio_source_repo: RadioSourceRepository,
+ ):
"""Test duplicate check when no duplicate exists."""
# Arrange
mock_radio_source_repo.find_by_url.return_value = None
# Act
- is_duplicate = validation_service.check_duplicate_stream_url("https://stream.example.com/radio.mp3")
+ is_duplicate = validation_service.check_duplicate_stream_url(
+ "https://stream.example.com/radio.mp3"
+ )
# Assert
assert not is_duplicate
- def test_check_duplicate_stream_url_duplicate_exists(self, validation_service: ProposalValidationService, mock_radio_source_repo: RadioSourceRepository):
+ def test_check_duplicate_stream_url_duplicate_exists(
+ self,
+ validation_service: ProposalValidationService,
+ mock_radio_source_repo: RadioSourceRepository,
+ ):
"""Test duplicate check when duplicate exists."""
# Arrange
- mock_radio_source_repo.find_by_url.return_value = RadioSource(id=1, stream_url="https://stream.example.com/radio.mp3", name="Existing Radio", website_url="https://existing.com", stream_type_id=1, is_secure=True)
+ mock_radio_source_repo.find_by_url.return_value = RadioSource(
+ id=1,
+ stream_url="https://stream.example.com/radio.mp3",
+ name="Existing Radio",
+ website_url="https://existing.com",
+ stream_type_id=1,
+ is_secure=True,
+ )
# Act
- is_duplicate = validation_service.check_duplicate_stream_url("https://stream.example.com/radio.mp3")
+ is_duplicate = validation_service.check_duplicate_stream_url(
+ "https://stream.example.com/radio.mp3"
+ )
# Assert
- assert is_duplicate
\ No newline at end of file
+ assert is_duplicate
diff --git a/tests/unit/test_radio_source_service.py b/tests/unit/test_radio_source_service.py
index 4f307f9..d0a235c 100644
--- a/tests/unit/test_radio_source_service.py
+++ b/tests/unit/test_radio_source_service.py
@@ -16,7 +16,7 @@
from service.proposal_validation_service import ProposalValidationService
from model.entity.proposal import Proposal
from model.entity.radio_source import RadioSource
-from model.entity.stream_analysis import StreamAnalysis
+
from model.dto.validation import ProposalUpdateRequest
from model.dto.validation import ValidationResult
@@ -28,24 +28,29 @@ def mock_proposal_repo() -> ProposalRepository:
"""Create mock ProposalRepository."""
return Mock(spec=ProposalRepository)
+
@pytest.fixture
def mock_radio_source_repo() -> RadioSourceRepository:
"""Create mock RadioSourceRepository."""
return Mock(spec=RadioSourceRepository)
+
@pytest.fixture
def mock_validation_service() -> ProposalValidationService:
"""Create mock ProposalValidationService."""
return Mock(spec=ProposalValidationService)
+
@pytest.fixture
-def radio_source_service(mock_proposal_repo: ProposalRepository,
- mock_radio_source_repo: RadioSourceRepository, mock_validation_service: ProposalValidationService) -> RadioSourceService:
+def radio_source_service(
+ mock_proposal_repo: ProposalRepository,
+ mock_radio_source_repo: RadioSourceRepository,
+ mock_validation_service: ProposalValidationService,
+) -> RadioSourceService:
"""Create RadioSourceService with mocked dependencies."""
return RadioSourceService(
- mock_proposal_repo,
- mock_radio_source_repo,
- mock_validation_service)
+ mock_proposal_repo, mock_radio_source_repo, mock_validation_service
+ )
class TestRadioSourceService:
@@ -56,7 +61,7 @@ def test_save_from_proposal_success(
radio_source_service,
mock_proposal_repo,
mock_radio_source_repo,
- mock_validation_service
+ mock_validation_service,
):
"""Test successfully saving a valid proposal as RadioSource."""
@@ -70,7 +75,7 @@ def test_save_from_proposal_success(
is_secure=True,
country="Italy",
description="Test description",
- image_url="test.jpg"
+ image_url="test.jpg",
)
validation_result = ValidationResult(is_valid=True)
@@ -87,7 +92,7 @@ def test_save_from_proposal_success(
country="Italy",
description="Test description",
image_url="test.jpg",
- created_at=datetime.now()
+ created_at=datetime.now(),
)
# Act
@@ -101,28 +106,26 @@ def test_save_from_proposal_success(
mock_radio_source_repo.save.assert_called_once()
mock_proposal_repo.delete.assert_called_once_with(1)
+
def test_save_from_proposal_validation_failure(
- self,
- radio_source_service,
- mock_validation_service
+ self, radio_source_service, mock_validation_service
):
"""Test saving fails when validation fails."""
# Arrange
validation_result = ValidationResult(
- is_valid=False,
- errors=["Stream URL is required"]
+ is_valid=False, errors=["Stream URL is required"]
)
mock_validation_service.validate_proposal.return_value = validation_result
# Act & Assert
- with pytest.raises(ValueError, match="Proposal validation failed: Stream URL is required"):
+ with pytest.raises(
+ ValueError, match="Proposal validation failed: Stream URL is required"
+ ):
radio_source_service.save_from_proposal(1)
+
def test_save_from_proposal_not_found(
- self,
- radio_source_service,
- mock_proposal_repo,
- mock_validation_service
+ self, radio_source_service, mock_proposal_repo, mock_validation_service
):
"""Test saving fails when proposal not found."""
# Arrange
@@ -134,11 +137,8 @@ def test_save_from_proposal_not_found(
with pytest.raises(ValueError, match="Proposal with ID 1 not found"):
radio_source_service.save_from_proposal(1)
- def test_reject_proposal_success(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+
+ def test_reject_proposal_success(self, radio_source_service, mock_proposal_repo):
"""Test successfully rejecting a proposal."""
# Arrange
proposal = Proposal(
@@ -147,7 +147,7 @@ def test_reject_proposal_success(
name="Test Radio",
website_url="https://example.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
)
mock_proposal_repo.find_by_id.return_value = proposal
@@ -158,11 +158,8 @@ def test_reject_proposal_success(
assert result
mock_proposal_repo.delete.assert_called_once_with(1)
- def test_reject_proposal_not_found(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+
+ def test_reject_proposal_not_found(self, radio_source_service, mock_proposal_repo):
"""Test rejecting fails when proposal not found."""
# Arrange
mock_proposal_repo.find_by_id.return_value = None
@@ -173,18 +170,15 @@ def test_reject_proposal_not_found(
# Assert
assert not result
- def test_update_proposal_success(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+
+ def test_update_proposal_success(self, radio_source_service, mock_proposal_repo):
"""Test successfully updating proposal data."""
# Arrange
update_request = ProposalUpdateRequest(
name="Updated Radio Name",
website_url="https://updated.example.com",
country="Updated Country",
- description="Updated description"
+ description="Updated description",
)
proposal = Proposal(
@@ -195,7 +189,7 @@ def test_update_proposal_success(
stream_type_id=1,
is_secure=True,
country="Original Country",
- description="Original description"
+ description="Original description",
)
mock_proposal_repo.find_by_id.return_value = proposal
@@ -207,7 +201,7 @@ def test_update_proposal_success(
stream_type_id=1,
is_secure=True,
country="Updated Country",
- description="Updated description"
+ description="Updated description",
)
mock_proposal_repo.save.return_value = updated_proposal
@@ -220,11 +214,7 @@ def test_update_proposal_success(
assert result.country == "Updated Country"
assert result.description == "Updated description"
- def test_update_proposal_not_found(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+ def test_update_proposal_not_found(self, radio_source_service, mock_proposal_repo):
"""Test updating fails when proposal not found."""
# Arrange
update_request = ProposalUpdateRequest(name="New Name")
@@ -234,11 +224,7 @@ def test_update_proposal_not_found(
with pytest.raises(ValueError, match="Proposal with ID 1 not found"):
radio_source_service.update_proposal(1, update_request)
- def test_get_proposal(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+ def test_get_proposal(self, radio_source_service, mock_proposal_repo):
"""Test getting a proposal by ID."""
# Arrange
proposal = Proposal(
@@ -247,7 +233,7 @@ def test_get_proposal(
name="Test Radio",
website_url="https://example.com",
stream_type_id=1,
- is_secure=True
+ is_secure=True,
)
mock_proposal_repo.find_by_id.return_value = proposal
@@ -257,16 +243,26 @@ def test_get_proposal(
# Assert
assert result == proposal
- def test_get_all_proposals(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+ def test_get_all_proposals(self, radio_source_service, mock_proposal_repo):
"""Test getting all proposals."""
# Arrange
proposals = [
- Proposal(id=1, stream_url="url1", name="Radio 1", website_url="web1", stream_type_id=1, is_secure=True),
- Proposal(id=2, stream_url="url2", name="Radio 2", website_url="web2", stream_type_id=2, is_secure=False)
+ Proposal(
+ id=1,
+ stream_url="url1",
+ name="Radio 1",
+ website_url="web1",
+ stream_type_id=1,
+ is_secure=True,
+ ),
+ Proposal(
+ id=2,
+ stream_url="url2",
+ name="Radio 2",
+ website_url="web2",
+ stream_type_id=2,
+ is_secure=False,
+ ),
]
mock_proposal_repo.get_all_proposals.return_value = proposals
@@ -276,27 +272,8 @@ def test_get_all_proposals(
# Assert
assert result == proposals
- def test_reject_proposal(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
- """Test successfully rejecting a proposal."""
- # Arrange
- mock_proposal_repo.delete.return_value = True
-
- # Act
- result = radio_source_service.reject_proposal(1)
-
- # Assert
- assert result is True
- mock_proposal_repo.delete.assert_called_once_with(1)
- def test_reject_proposal_not_found(
- self,
- radio_source_service,
- mock_proposal_repo
- ):
+ def test_reject_proposal_not_found(self, radio_source_service, mock_proposal_repo):
"""Test rejecting a proposal that doesn't exist."""
# Arrange
mock_proposal_repo.delete.return_value = False
@@ -308,16 +285,28 @@ def test_reject_proposal_not_found(
assert result is False
mock_proposal_repo.delete.assert_called_once_with(1)
- def test_get_all_radio_sources(
- self,
- radio_source_service,
- mock_radio_source_repo
- ):
+ def test_get_all_radio_sources(self, radio_source_service, mock_radio_source_repo):
"""Test getting all radio sources."""
# Arrange
radio_sources = [
- RadioSource(id=1, stream_url="url1", name="Radio 1", website_url="web1", stream_type_id=1, is_secure=True, created_at=datetime.now()),
- RadioSource(id=2, stream_url="url2", name="Radio 2", website_url="web2", stream_type_id=2, is_secure=False, created_at=datetime.now())
+ RadioSource(
+ id=1,
+ stream_url="url1",
+ name="Radio 1",
+ website_url="web1",
+ stream_type_id=1,
+ is_secure=True,
+ created_at=datetime.now(),
+ ),
+ RadioSource(
+ id=2,
+ stream_url="url2",
+ name="Radio 2",
+ website_url="web2",
+ stream_type_id=2,
+ is_secure=False,
+ created_at=datetime.now(),
+ ),
]
mock_radio_source_repo.find_all.return_value = radio_sources
@@ -325,4 +314,4 @@ def test_get_all_radio_sources(
result = radio_source_service.get_all_radio_sources()
# Assert
- assert result == radio_sources
\ No newline at end of file
+ assert result == radio_sources
diff --git a/tests/unit/test_stream_analysis_service.py b/tests/unit/test_stream_analysis_service.py
index aece540..25c2d6f 100644
--- a/tests/unit/test_stream_analysis_service.py
+++ b/tests/unit/test_stream_analysis_service.py
@@ -31,6 +31,7 @@ def mock_stream_type_service() -> StreamTypeService:
# cast to the interface/class so the fixture's return type is StreamTypeService (Pylance-friendly)
return cast(StreamTypeService, mock_service)
+
@pytest.fixture
def mock_stream_analysis_repo() -> StreamAnalysisRepository:
"""Mock StreamAnalysisRepository for testing."""
@@ -46,19 +47,30 @@ def mock_proposal_repo() -> ProposalRepository:
@pytest.fixture
-def analysis_service(mock_stream_type_service: StreamTypeService, mock_proposal_repo: ProposalRepository, mock_stream_analysis_repo: StreamAnalysisRepository) -> StreamAnalysisService:
+def analysis_service(
+ mock_stream_type_service: StreamTypeService,
+ mock_proposal_repo: ProposalRepository,
+ mock_stream_analysis_repo: StreamAnalysisRepository,
+) -> StreamAnalysisService:
"""Create StreamAnalysisService with mocked dependencies."""
# patch shutil.which only during construction so the constructor doesn't raise
- with patch('service.stream_analysis_service.shutil.which', return_value='/usr/bin/ffmpeg'):
- service = StreamAnalysisService(stream_type_service=mock_stream_type_service,
- proposal_repository=mock_proposal_repo, analysis_repository=mock_stream_analysis_repo)
+ with patch(
+ "service.stream_analysis_service.shutil.which", return_value="/usr/bin/ffmpeg"
+ ):
+ service = StreamAnalysisService(
+ stream_type_service=mock_stream_type_service,
+ proposal_repository=mock_proposal_repo,
+ analysis_repository=mock_stream_analysis_repo,
+ )
return service
class TestStreamAnalysisService:
"""Test cases for StreamAnalysisService."""
- def test_unsupported_protocol_rejection(self, analysis_service: StreamAnalysisService) -> None:
+ def test_unsupported_protocol_rejection(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test FR-004: RTMP/RTSP URLs are rejected."""
rtmp_url = "rtmp://stream.example.com/live"
@@ -68,79 +80,103 @@ def test_unsupported_protocol_rejection(self, analysis_service: StreamAnalysisSe
assert result.error_code == ErrorCode.UNSUPPORTED_PROTOCOL
assert not result.is_secure
- def test_https_security_detection(self, analysis_service: StreamAnalysisService) -> None:
+ def test_https_security_detection(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test security detection for HTTPS URLs."""
https_url = "https://stream.example.com/radio.mp3"
- with patch.object(analysis_service, '_analyze_with_curl') as mock_curl, \
- patch.object(analysis_service, '_analyze_with_ffmpeg') as mock_ffmpeg:
+ with patch.object(
+ analysis_service, "_analyze_with_curl"
+ ) as mock_curl, patch.object(
+ analysis_service, "_analyze_with_ffmpeg"
+ ) as mock_ffmpeg:
# Mock successful analysis
mock_curl.return_value = {
"success": True,
"content_type": "audio/mpeg",
"raw_output": "HTTP/1.1 200 OK\\nContent-Type: audio/mpeg",
- "security_status": "SAFE"
+ "security_status": "SAFE",
}
mock_ffmpeg.return_value = {
"success": True,
"format": "MP3",
"codec": "mp3",
"raw_output": "Stream #0:0: Audio: mp3 (mp3float), 22050 Hz, mono, fltp, 24 kb/s",
- "security_status": "SAFE"
+ "security_status": "SAFE",
}
result = analysis_service.analyze_stream(https_url)
assert result.is_secure # HTTPS should be secure
- def test_http_security_warning(self, analysis_service: StreamAnalysisService) -> None:
+ def test_http_security_warning(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test FR-005: HTTP streams flagged as insecure but valid."""
http_url = "http://stream.example.com:8000/"
- with patch.object(analysis_service, '_analyze_with_curl') as mock_curl, \
- patch.object(analysis_service, '_analyze_with_ffmpeg') as mock_ffmpeg:
+ with patch.object(
+ analysis_service, "_analyze_with_curl"
+ ) as mock_curl, patch.object(
+ analysis_service, "_analyze_with_ffmpeg"
+ ) as mock_ffmpeg:
mock_curl.return_value = {
"success": True,
"content_type": "audio/mpeg",
"raw_output": "HTTP/1.1 200 OK\\nContent-Type: audio/mpeg",
- "security_status": "UNSAFE"
+ "security_status": "UNSAFE",
}
mock_ffmpeg.return_value = {
"success": True,
"format": "MP3",
"codec": "mp3",
"raw_output": "Stream #0:0: Audio: mp3",
- "security_status": "UNSAFE"
+ "security_status": "UNSAFE",
}
result: StreamAnalysisResult = analysis_service.analyze_stream(http_url)
assert not result.is_secure # HTTP should be insecure
- assert result.is_valid # But still valid
+ assert result.is_valid # But still valid
- @patch('subprocess.run')
- def test_ffmpeg_authoritative_over_curl(self, mock_run: Mock, analysis_service: StreamAnalysisService) -> None:
+ @patch("subprocess.run")
+ def test_ffmpeg_authoritative_over_curl(
+ self, mock_run: Mock, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test FR-003: FFmpeg is authoritative when results differ."""
# Mock curl detecting MP3, but ffmpeg detecting AAC
curl_responses: list[Mock] = [
# First call: curl -I
- Mock(returncode=0, stdout="HTTP/1.1 200 OK\\nContent-Type: audio/mpeg\\n", stderr=""),
+ Mock(
+ returncode=0,
+ stdout="HTTP/1.1 200 OK\\nContent-Type: audio/mpeg\\n",
+ stderr="",
+ ),
# Second call: ffmpeg -i
- Mock(returncode=0, stdout="", stderr="Stream #0:0: Audio: aac, 44100 Hz, stereo")
+ Mock(
+ returncode=0,
+ stdout="",
+ stderr="Stream #0:0: Audio: aac, 44100 Hz, stereo",
+ ),
]
mock_run.side_effect = curl_responses
- result: StreamAnalysisResult = analysis_service.analyze_stream("https://stream.example.com/test")
+ result: StreamAnalysisResult = analysis_service.analyze_stream(
+ "https://stream.example.com/test"
+ )
# Should use FFmpeg's AAC detection, not curl's MP3
assert result.detection_method == DetectionMethod.BOTH
# The mock should have called find_stream_type_id with AAC format
- def test_curl_header_extraction(self, analysis_service: StreamAnalysisService) -> None:
+ def test_curl_header_extraction(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test _extract_content_type method."""
headers = "HTTP/1.1 200 OK\\nContent-Type: audio/mpeg\\nServer: Icecast\\n"
@@ -148,7 +184,9 @@ def test_curl_header_extraction(self, analysis_service: StreamAnalysisService) -
print("Extracted Content-Type:", content_type)
assert content_type == "audio/mpeg"
- def test_ffmpeg_output_parsing(self, analysis_service: StreamAnalysisService) -> None:
+ def test_ffmpeg_output_parsing(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test _parse_ffmpeg_output method."""
ffmpeg_output = "Input #0, mp3, from 'stream':\\nStream #0:0: Audio: mp3 (mp3float), 22050 Hz, mono, fltp, 24 kb/s"
@@ -157,7 +195,9 @@ def test_ffmpeg_output_parsing(self, analysis_service: StreamAnalysisService) ->
assert result["format"] == "MP3"
assert result["codec"] == "mp3"
- def test_metadata_detection_icecast(self, analysis_service: StreamAnalysisService) -> None:
+ def test_metadata_detection_icecast(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test metadata detection for Icecast streams."""
headers = "HTTP/1.1 200 OK\\nicy-name: Test Radio\\nServer: Icecast"
@@ -165,7 +205,9 @@ def test_metadata_detection_icecast(self, analysis_service: StreamAnalysisServic
assert metadata == "Icecast"
- def test_metadata_detection_shoutcast(self, analysis_service: StreamAnalysisService) -> None:
+ def test_metadata_detection_shoutcast(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test metadata detection for Shoutcast streams."""
headers = "HTTP/1.1 200 OK\\nServer: Shoutcast\\nicy-genre: Rock"
@@ -177,22 +219,31 @@ def test_prerequisites_check_missing_ffmpeg(self):
"""Test NFR-001: Prerequisites check for missing ffmpeg."""
mock_service = Mock()
- with patch('service.stream_analysis_service.shutil.which', return_value=None):
+ with patch("service.stream_analysis_service.shutil.which", return_value=None):
with pytest.raises(RuntimeError, match="ffmpeg is not installed"):
- StreamAnalysisService(mock_service, proposal_repository=Mock(), analysis_repository=Mock())
-
- @patch('subprocess.run')
- def test_timeout_handling(self, mock_run, analysis_service: StreamAnalysisService) -> None:
+ StreamAnalysisService(
+ mock_service, proposal_repository=Mock(), analysis_repository=Mock()
+ )
+
+ @patch("subprocess.run")
+ def test_timeout_handling(
+ self, mock_run, analysis_service: StreamAnalysisService
+ ) -> None:
"""Test NFR-002: Timeout handling."""
from subprocess import TimeoutExpired
- mock_run.side_effect = TimeoutExpired('curl', 30)
- result = analysis_service.analyze_stream("https://slow.example.com/stream", timeout_seconds=1)
+ mock_run.side_effect = TimeoutExpired("curl", 30)
+
+ result = analysis_service.analyze_stream(
+ "https://slow.example.com/stream", timeout_seconds=1
+ )
assert not result.is_valid
assert result.error_code == ErrorCode.TIMEOUT
- def test_extract_metadata_from_ffmpeg_output_basic(self, analysis_service: StreamAnalysisService) -> None:
+ def test_extract_metadata_from_ffmpeg_output_basic(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Unit test for _extract_metadata_from_ffmpeg_output helper."""
ffmpeg_stderr = (
"Input #0, mp3, from 'stream':\n"
@@ -205,7 +256,9 @@ def test_extract_metadata_from_ffmpeg_output_basic(self, analysis_service: Strea
extracted = analysis_service._extract_metadata_from_ffmpeg_output(ffmpeg_stderr)
assert extracted == "title: Test Title\nartist: Example Artist"
- def test_analyze_stream_populates_extracted_metadata_from_ffmpeg(self, analysis_service: StreamAnalysisService) -> None:
+ def test_analyze_stream_populates_extracted_metadata_from_ffmpeg(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Integration-style test: ensure analyze_stream result contains extracted_metadata."""
url = "https://stream.example.com/test"
ffmpeg_stderr = (
@@ -216,13 +269,16 @@ def test_analyze_stream_populates_extracted_metadata_from_ffmpeg(self, analysis_
" Stream #0:0: Audio: mp3 (mp3float), 22050 Hz, mono"
)
- with patch.object(analysis_service, '_analyze_with_curl') as mock_curl, \
- patch.object(analysis_service, '_analyze_with_ffmpeg') as mock_ffmpeg:
+ with patch.object(
+ analysis_service, "_analyze_with_curl"
+ ) as mock_curl, patch.object(
+ analysis_service, "_analyze_with_ffmpeg"
+ ) as mock_ffmpeg:
mock_curl.return_value = {
"success": True,
"content_type": "audio/mpeg",
- "raw_output": "HTTP/1.1 200 OK\nContent-Type: audio/mpeg"
+ "raw_output": "HTTP/1.1 200 OK\nContent-Type: audio/mpeg",
}
# Return ffmpeg result including extracted_metadata (what our helper would produce)
@@ -231,17 +287,21 @@ def test_analyze_stream_populates_extracted_metadata_from_ffmpeg(self, analysis_
"format": "MP3",
"codec": "mp3",
"raw_output": ffmpeg_stderr,
- "extracted_metadata": "title: Test Title\nartist: Example Artist"
+ "extracted_metadata": "title: Test Title\nartist: Example Artist",
}
result = analysis_service.analyze_stream(url)
assert result.raw_ffmpeg_output == ffmpeg_stderr
- assert result.extracted_metadata == "title: Test Title\nartist: Example Artist"
+ assert (
+ result.extracted_metadata == "title: Test Title\nartist: Example Artist"
+ )
- def test_save_analysis_as_proposal_basic(self, analysis_service: StreamAnalysisService) -> None:
+ def test_save_analysis_as_proposal_basic(
+ self, analysis_service: StreamAnalysisService
+ ) -> None:
"""Unit test promoting a analysis into a proposal."""
- with patch.object(analysis_service, 'analyze_stream') as mock_stream_analysis:
+ with patch.object(analysis_service, "analyze_stream") as mock_stream_analysis:
mock_stream_analysis.return_value = StreamAnalysisResult(
stream_url="https://stream.example.com/test",
stream_type_display_name="Test Stream",
@@ -250,8 +310,10 @@ def test_save_analysis_as_proposal_basic(self, analysis_service: StreamAnalysisS
stream_type_id=1,
is_secure=True,
raw_ffmpeg_output="Stream #0:0: Audio: mp3",
- extracted_metadata="title: Test Title\nartist: Example Artist"
+ extracted_metadata="title: Test Title\nartist: Example Artist",
+ )
+
+ result: bool = analysis_service.save_analysis_as_proposal(
+ mock_stream_analysis.return_value
)
-
- result: bool = analysis_service.save_analysis_as_proposal(mock_stream_analysis.return_value)
- assert result is True
\ No newline at end of file
+ assert result is True
diff --git a/tests/unit/test_stream_type_service.py b/tests/unit/test_stream_type_service.py
index 2b23e72..57a4187 100644
--- a/tests/unit/test_stream_type_service.py
+++ b/tests/unit/test_stream_type_service.py
@@ -8,11 +8,13 @@
from service.stream_type_service import StreamTypeService
from model.dto.stream_type import StreamTypeDTO
+
@pytest.fixture
def mock_repository() -> StreamTypeRepository:
"""Mock StreamTypeRepository for testing."""
return Mock()
+
@pytest.fixture
def stream_type_service(mock_repository: StreamTypeRepository) -> StreamTypeService:
"""Create StreamTypeService with mocked repository."""
@@ -22,16 +24,26 @@ def stream_type_service(mock_repository: StreamTypeRepository) -> StreamTypeServ
class TestStreamTypeService:
"""Test cases for StreamTypeService."""
- def test_find_stream_type_id(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ def test_find_stream_type_id(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test finding stream type ID by combination."""
mock_repository.find_by_combination.return_value = 5
result = stream_type_service.find_stream_type_id("HTTPS", "MP3", "Icecast")
assert result == 5
- mock_repository.find_by_combination.assert_called_once_with("HTTPS", "MP3", "Icecast")
-
- def test_find_stream_type_id_not_found(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ mock_repository.find_by_combination.assert_called_once_with(
+ "HTTPS", "MP3", "Icecast"
+ )
+
+ def test_find_stream_type_id_not_found(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test finding stream type ID when not found."""
mock_repository.find_by_combination.return_value = None
@@ -39,7 +51,11 @@ def test_find_stream_type_id_not_found(self, stream_type_service: StreamTypeServ
assert result is None
- def test_get_stream_type(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ def test_get_stream_type(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test getting stream type by ID."""
# Mock the entity returned by repository
mock_entity = Mock()
@@ -59,7 +75,11 @@ def test_get_stream_type(self, stream_type_service: StreamTypeService, mock_repo
assert result.metadata == "Icecast"
assert result.display_name == "HTTPS MP3 Icecast"
- def test_get_stream_type_not_found(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ def test_get_stream_type_not_found(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test getting stream type when not found."""
mock_repository.get_by_id.return_value = None
@@ -67,12 +87,28 @@ def test_get_stream_type_not_found(self, stream_type_service: StreamTypeService,
assert result is None
- def test_get_all_stream_types(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ def test_get_all_stream_types(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test getting all stream types."""
# Mock entities
mock_entities = [
- Mock(id=1, protocol="HTTP", format="MP3", metadata_type="Icecast", display_name="HTTP MP3 Icecast"),
- Mock(id=2, protocol="HTTPS", format="AAC", metadata_type="Shoutcast", display_name="HTTPS AAC Shoutcast")
+ Mock(
+ id=1,
+ protocol="HTTP",
+ format="MP3",
+ metadata_type="Icecast",
+ display_name="HTTP MP3 Icecast",
+ ),
+ Mock(
+ id=2,
+ protocol="HTTPS",
+ format="AAC",
+ metadata_type="Shoutcast",
+ display_name="HTTPS AAC Shoutcast",
+ ),
]
mock_repository.get_all.return_value = mock_entities
@@ -83,11 +119,15 @@ def test_get_all_stream_types(self, stream_type_service: StreamTypeService, mock
assert result[0].protocol == "HTTP"
assert result[1].protocol == "HTTPS"
- def test_get_predefined_types_map(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ def test_get_predefined_types_map(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test getting predefined types map."""
mock_repository.get_type_key_to_id_map.return_value = {
"HTTP-MP3-Icecast": 1,
- "HTTPS-AAC-Shoutcast": 2
+ "HTTPS-AAC-Shoutcast": 2,
}
result = stream_type_service.get_predefined_types_map()
@@ -95,7 +135,11 @@ def test_get_predefined_types_map(self, stream_type_service: StreamTypeService,
assert result == {"HTTP-MP3-Icecast": 1, "HTTPS-AAC-Shoutcast": 2}
mock_repository.get_type_key_to_id_map.assert_called_once()
- def test_initialize_predefined_types(self, stream_type_service: StreamTypeService, mock_repository: StreamTypeRepository):
+ def test_initialize_predefined_types(
+ self,
+ stream_type_service: StreamTypeService,
+ mock_repository: StreamTypeRepository,
+ ):
"""Test initializing predefined types."""
stream_type_service.initialize_predefined_types()
@@ -104,4 +148,7 @@ def test_initialize_predefined_types(self, stream_type_service: StreamTypeServic
# Check one of the calls
calls = mock_repository.create_if_not_exists.call_args_list
- assert any(call[0] == ("HTTP", "MP3", "Icecast", "HTTP MP3 with Icecast metadata") for call in calls)
\ No newline at end of file
+ assert any(
+ call[0] == ("HTTP", "MP3", "Icecast", "HTTP MP3 with Icecast metadata")
+ for call in calls
+ )