mirror of
https://github.com/sstent/foodplanner.git
synced 2026-01-25 11:11:36 +00:00
refactored
This commit is contained in:
29
TDD_RULES.md
29
TDD_RULES.md
@@ -1,29 +0,0 @@
|
|||||||
# TDD Development Rules
|
|
||||||
|
|
||||||
This document outlines the rules for Test-Driven Development (TDD) using pytest within our containerized environment.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
- **Test-First Approach**: All new features must begin with writing a failing test.
|
|
||||||
- **Debug-Driven Tests**: When a bug is identified during debugging, a new pytest must be created to reproduce the bug and validate its fix.
|
|
||||||
|
|
||||||
## Pytest Execution
|
|
||||||
|
|
||||||
Pytest should be run within the Docker Compose environment to ensure consistency and isolation.
|
|
||||||
|
|
||||||
### Running Specific Tests
|
|
||||||
|
|
||||||
To run tests for a specific file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose build
|
|
||||||
docker compose run --remove-orphans foodtracker pytest tests/<test filename>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running All Tests
|
|
||||||
|
|
||||||
To run all tests in the project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose build
|
|
||||||
docker compose run --remove-orphans foodtracker pytest
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Database migration script to add new fields for food sources and meal times.
|
|
||||||
Run this script to update the database schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from main import Base, engine
|
|
||||||
|
|
||||||
def migrate_database():
|
|
||||||
"""Add new columns to existing tables"""
|
|
||||||
|
|
||||||
# Connect to database
|
|
||||||
conn = sqlite3.connect('./meal_planner.db')
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if source column exists in foods table
|
|
||||||
cursor.execute("PRAGMA table_info(foods)")
|
|
||||||
columns = cursor.fetchall()
|
|
||||||
column_names = [col[1] for col in columns]
|
|
||||||
|
|
||||||
if 'source' not in column_names:
|
|
||||||
print("Adding 'source' column to foods table...")
|
|
||||||
cursor.execute("ALTER TABLE foods ADD COLUMN source TEXT DEFAULT 'manual'")
|
|
||||||
print("✓ Added source column to foods table")
|
|
||||||
|
|
||||||
# Check if meal_time column exists in meals table
|
|
||||||
cursor.execute("PRAGMA table_info(meals)")
|
|
||||||
columns = cursor.fetchall()
|
|
||||||
column_names = [col[1] for col in columns]
|
|
||||||
|
|
||||||
if 'meal_time' not in column_names:
|
|
||||||
print("Adding 'meal_time' column to meals table...")
|
|
||||||
cursor.execute("ALTER TABLE meals ADD COLUMN meal_time TEXT DEFAULT 'Breakfast'")
|
|
||||||
print("✓ Added meal_time column to meals table")
|
|
||||||
|
|
||||||
# Check if meal_time column exists in plans table
|
|
||||||
cursor.execute("PRAGMA table_info(plans)")
|
|
||||||
columns = cursor.fetchall()
|
|
||||||
column_names = [col[1] for col in columns]
|
|
||||||
|
|
||||||
if 'meal_time' not in column_names:
|
|
||||||
print("Adding 'meal_time' column to plans table...")
|
|
||||||
cursor.execute("ALTER TABLE plans ADD COLUMN meal_time TEXT DEFAULT 'Breakfast'")
|
|
||||||
print("✓ Added meal_time column to plans table")
|
|
||||||
|
|
||||||
# Update existing records to have proper source values
|
|
||||||
print("Updating existing food records with source information...")
|
|
||||||
|
|
||||||
# Set source to 'csv' for foods that might have been imported
|
|
||||||
# Note: This is a heuristic - you may need to adjust based on your data
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE foods
|
|
||||||
SET source = 'csv'
|
|
||||||
WHERE name LIKE '%(%'
|
|
||||||
AND source = 'manual'
|
|
||||||
""")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
print("✓ Migration completed successfully!")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Migration failed: {e}")
|
|
||||||
conn.rollback()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("Starting database migration...")
|
|
||||||
migrate_database()
|
|
||||||
print("Migration script completed.")
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
from sqlalchemy import create_engine, Column, Integer, String, Float
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
|
|
||||||
DATABASE_URL = f"sqlite:///{os.getenv('DATABASE_PATH', './data')}/meal_planner.db"
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
class Food(Base):
|
|
||||||
__tablename__ = "foods"
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String, unique=True, index=True)
|
|
||||||
brand = Column(String, default="") # New field
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def migrate_food_brands():
|
|
||||||
db = next(get_db())
|
|
||||||
foods = db.query(Food).all()
|
|
||||||
|
|
||||||
updated_count = 0
|
|
||||||
for food in foods:
|
|
||||||
# Check if the name contains a brand in parentheses
|
|
||||||
match = re.search(r'\s*\((\w[^)]*)\)$', food.name)
|
|
||||||
if match:
|
|
||||||
brand_name = match.group(1).strip()
|
|
||||||
# If brand is found and not already set, update
|
|
||||||
if not food.brand and brand_name:
|
|
||||||
food.brand = brand_name
|
|
||||||
# Optionally remove brand from name
|
|
||||||
food.name = re.sub(r'\s*\((\w[^)]*)\)$', '', food.name).strip()
|
|
||||||
updated_count += 1
|
|
||||||
print(f"Updated food '{food.name}' with brand '{food.brand}'")
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
print(f"Migration complete. Updated {updated_count} food brands.")
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("Starting food brand migration...")
|
|
||||||
# This will add the 'brand' column if it doesn't exist.
|
|
||||||
# Note: For SQLite, ALTER TABLE ADD COLUMN is limited.
|
|
||||||
# If the column already exists and you're just populating, Base.metadata.create_all() is fine.
|
|
||||||
# If you're adding a new column to an existing table, you might need Alembic for proper migrations.
|
|
||||||
# For this task, we'll assume the column is added manually or via a previous step.
|
|
||||||
# Base.metadata.create_all(bind=engine) # This line should only be run if the table/column is new and not yet in DB
|
|
||||||
|
|
||||||
# We need to reflect the existing table schema to ensure the 'brand' column is known
|
|
||||||
# by SQLAlchemy before attempting to set its value.
|
|
||||||
# For a real-world scenario, a proper migration tool like Alembic would handle schema changes.
|
|
||||||
# For this simplified example, we assume the 'brand' column already exists in the DB or will be added manually.
|
|
||||||
|
|
||||||
migrate_food_brands()
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Migration script to convert Plan.date from String to Date type
|
|
||||||
and convert existing "DayX" values to actual calendar dates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
def migrate_plans_to_dates():
|
|
||||||
"""Convert existing DayX plans to actual dates"""
|
|
||||||
|
|
||||||
# Connect to database
|
|
||||||
conn = sqlite3.connect('meal_planner.db')
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if migration is needed
|
|
||||||
cursor.execute("PRAGMA table_info(plans)")
|
|
||||||
columns = cursor.fetchall()
|
|
||||||
date_column_type = None
|
|
||||||
|
|
||||||
for col in columns:
|
|
||||||
if col[1] == 'date': # column name
|
|
||||||
date_column_type = col[2] # column type
|
|
||||||
break
|
|
||||||
|
|
||||||
if date_column_type and 'DATE' in date_column_type.upper():
|
|
||||||
print("Migration already completed - date column is already DATE type")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Starting migration from String to Date...")
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
print("Creating backup of plans table...")
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE plans_backup AS
|
|
||||||
SELECT * FROM plans
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Add new date column
|
|
||||||
print("Adding new date column...")
|
|
||||||
cursor.execute("""
|
|
||||||
ALTER TABLE plans ADD COLUMN date_new DATE
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Convert DayX to actual dates (starting from today as Day1)
|
|
||||||
print("Converting DayX values to dates...")
|
|
||||||
today = datetime.now().date()
|
|
||||||
|
|
||||||
# Get all unique day values
|
|
||||||
cursor.execute("SELECT DISTINCT date FROM plans WHERE date LIKE 'Day%'")
|
|
||||||
day_values = cursor.fetchall()
|
|
||||||
|
|
||||||
for (day_str,) in day_values:
|
|
||||||
if day_str.startswith('Day'):
|
|
||||||
try:
|
|
||||||
day_num = int(day_str[3:]) # Extract number from "Day1", "Day2", etc.
|
|
||||||
# Convert to date (Day1 = today, Day2 = tomorrow, etc.)
|
|
||||||
actual_date = today + timedelta(days=day_num - 1)
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE plans
|
|
||||||
SET date_new = ?
|
|
||||||
WHERE date = ?
|
|
||||||
""", (actual_date.isoformat(), day_str))
|
|
||||||
|
|
||||||
print(f"Converted {day_str} to {actual_date.isoformat()}")
|
|
||||||
|
|
||||||
except (ValueError, IndexError) as e:
|
|
||||||
print(f"Error converting {day_str}: {e}")
|
|
||||||
|
|
||||||
# Handle any non-DayX dates (if they exist)
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE plans
|
|
||||||
SET date_new = date
|
|
||||||
WHERE date NOT LIKE 'Day%' AND date_new IS NULL
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Recreate table with new structure (SQLite doesn't support DROP COLUMN with indexes)
|
|
||||||
print("Recreating table with new structure...")
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE plans_new (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
person VARCHAR NOT NULL,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
meal_id INTEGER NOT NULL REFERENCES meals(id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Copy data to new table
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO plans_new (id, person, date, meal_id)
|
|
||||||
SELECT id, person, date_new, meal_id FROM plans
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Drop old table and rename new one
|
|
||||||
cursor.execute("DROP TABLE plans")
|
|
||||||
cursor.execute("ALTER TABLE plans_new RENAME TO plans")
|
|
||||||
|
|
||||||
# Create index on new date column
|
|
||||||
cursor.execute("CREATE INDEX ix_plans_date ON plans(date)")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
print("Migration completed successfully!")
|
|
||||||
|
|
||||||
# Show summary
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM plans")
|
|
||||||
total_plans = cursor.fetchone()[0]
|
|
||||||
print(f"Total plans migrated: {total_plans}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Migration failed: {e}")
|
|
||||||
conn.rollback()
|
|
||||||
|
|
||||||
# Restore backup if something went wrong
|
|
||||||
try:
|
|
||||||
cursor.execute("DROP TABLE IF EXISTS plans")
|
|
||||||
cursor.execute("ALTER TABLE plans_backup RENAME TO plans")
|
|
||||||
print("Restored from backup")
|
|
||||||
except Exception as backup_error:
|
|
||||||
print(f"Failed to restore backup: {backup_error}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate_plans_to_dates()
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Migration script to add is_modified column to tracked_days table.
|
|
||||||
Run this script to add the modification tracking functionality.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
def migrate_add_is_modified():
|
|
||||||
"""Add is_modified column to tracked_days table"""
|
|
||||||
db_path = "./meal_planner.db"
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
print("Database file not found. Please run the main application first to create the database.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if column already exists
|
|
||||||
cursor.execute("PRAGMA table_info(tracked_days)")
|
|
||||||
columns = cursor.fetchall()
|
|
||||||
column_names = [col[1] for col in columns]
|
|
||||||
|
|
||||||
if 'is_modified' in column_names:
|
|
||||||
print("Column 'is_modified' already exists in tracked_days table.")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Adding is_modified column to tracked_days table...")
|
|
||||||
|
|
||||||
# Add the column with default value 0 (False)
|
|
||||||
cursor.execute("ALTER TABLE tracked_days ADD COLUMN is_modified INTEGER DEFAULT 0")
|
|
||||||
|
|
||||||
# Update existing rows to have is_modified = 0 (not modified)
|
|
||||||
cursor.execute("UPDATE tracked_days SET is_modified = 0 WHERE is_modified IS NULL")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print("Migration completed successfully!")
|
|
||||||
print("Added column: is_modified (INTEGER, DEFAULT 0)")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Migration failed: {e}")
|
|
||||||
if 'conn' in locals():
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate_add_is_modified()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Migration script to add tracker tables for meal tracking functionality.
|
|
||||||
Run this script to add the necessary tables for the Tracker tab.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Date
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Database setup
|
|
||||||
DATABASE_URL = f"sqlite:///{os.getenv('DATABASE_PATH', './data')}/meal_planner.db"
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {})
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
# New models for tracker functionality
|
|
||||||
class TrackedDay(Base):
|
|
||||||
"""Represents a day being tracked (separate from planned days)"""
|
|
||||||
__tablename__ = "tracked_days"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
person = Column(String, index=True) # Sarah or Stuart
|
|
||||||
date = Column(Date, index=True) # Date being tracked
|
|
||||||
|
|
||||||
class TrackedMeal(Base):
|
|
||||||
"""Represents a meal tracked for a specific day"""
|
|
||||||
__tablename__ = "tracked_meals"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
tracked_day_id = Column(Integer) # Will add FK constraint later
|
|
||||||
meal_id = Column(Integer) # Will add FK constraint later
|
|
||||||
meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
|
|
||||||
quantity = Column(Float, default=1.0) # Quantity multiplier (e.g., 1.5 for 1.5 servings)
|
|
||||||
|
|
||||||
def migrate_tracker_tables():
|
|
||||||
"""Create the new tracker tables"""
|
|
||||||
try:
|
|
||||||
print("Creating tracker tables...")
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
print("Migration completed successfully!")
|
|
||||||
print("New tables created:")
|
|
||||||
print("- tracked_days: Stores individual days being tracked")
|
|
||||||
print("- tracked_meals: Stores meals for each tracked day")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Migration failed: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate_tracker_tables()
|
|
||||||
92
plan.md
92
plan.md
@@ -1,92 +0,0 @@
|
|||||||
# Plan for Adding New Tests to test_detailed.py
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This plan outlines the additional tests that need to be added to `tests/test_detailed.py` to cover the following functionality:
|
|
||||||
- Load today's date by default
|
|
||||||
- View date should return the meals planned for a date (already covered)
|
|
||||||
- The template dropdown should show a list of templates available to view
|
|
||||||
|
|
||||||
## Current Test Coverage
|
|
||||||
The existing tests in `tests/test_detailed.py` already cover:
|
|
||||||
- `test_detailed_page_no_params` - when no params are provided
|
|
||||||
- `test_detailed_page_with_plan_date` - when plan_date is provided
|
|
||||||
- `test_detailed_page_with_template_id` - when template_id is provided
|
|
||||||
- `test_detailed_page_with_invalid_plan_date` - when invalid plan_date is provided
|
|
||||||
- `test_detailed_page_with_invalid_template_id` - when invalid template_id is provided
|
|
||||||
|
|
||||||
## New Tests to Add
|
|
||||||
|
|
||||||
### 1. Test Default Date Loading
|
|
||||||
**Test Name:** `test_detailed_page_default_date`
|
|
||||||
**Purpose:** Verify that when no plan_date is provided, the detailed page loads with today's date by default
|
|
||||||
**Implementation:**
|
|
||||||
```python
|
|
||||||
def test_detailed_page_default_date(client, session):
|
|
||||||
# Create mock data for today
|
|
||||||
food = Food(name="Apple", serving_size="100", serving_unit="g", calories=52, protein=0.3, carbs=14, fat=0.2)
|
|
||||||
session.add(food)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(food)
|
|
||||||
|
|
||||||
meal = Meal(name="Fruit Snack", meal_type="snack", meal_time="Snack")
|
|
||||||
session.add(meal)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(meal)
|
|
||||||
|
|
||||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0)
|
|
||||||
session.add(meal_food)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
test_date = date.today()
|
|
||||||
plan = Plan(person="Sarah", date=test_date, meal_id=meal.id, meal_time="Snack")
|
|
||||||
session.add(plan)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Test that when no plan_date is provided, today's date is used by default
|
|
||||||
response = client.get("/detailed?person=Sarah")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Sarah's Detailed Plan for" in response.text
|
|
||||||
assert test_date.strftime('%B %d, %Y') in response.text # Check if today's date appears in the formatted date
|
|
||||||
assert "Fruit Snack" in response.text
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Test Template Dropdown
|
|
||||||
**Test Name:** `test_detailed_page_template_dropdown`
|
|
||||||
**Purpose:** Verify that the template dropdown shows available templates
|
|
||||||
**Implementation:**
|
|
||||||
```python
|
|
||||||
def test_detailed_page_template_dropdown(client, session):
|
|
||||||
# Create multiple templates
|
|
||||||
template1 = Template(name="Morning Boost")
|
|
||||||
template2 = Template(name="Evening Energy")
|
|
||||||
session.add(template1)
|
|
||||||
session.add(template2)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(template1)
|
|
||||||
session.refresh(template2)
|
|
||||||
|
|
||||||
# Test that the template dropdown shows available templates
|
|
||||||
response = client.get("/detailed")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Check that the response contains template selection UI elements
|
|
||||||
assert "Select Template..." in response.text
|
|
||||||
assert "Morning Boost" in response.text
|
|
||||||
assert "Evening Energy" in response.text
|
|
||||||
|
|
||||||
# Verify that template IDs are present in the dropdown options
|
|
||||||
assert f'value="{template1.id}"' in response.text
|
|
||||||
assert f'value="{template2.id}"' in response.text
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
- Both tests should use the existing session and client fixtures
|
|
||||||
- The tests should create necessary mock data to ensure proper functionality testing
|
|
||||||
- The date default test should verify that today's date appears in the response when no date is specified
|
|
||||||
- The template dropdown test should verify that templates are properly listed in the UI
|
|
||||||
|
|
||||||
## Expected Outcome
|
|
||||||
After implementing these tests, the test coverage for the detailed page will include:
|
|
||||||
- Default date loading functionality
|
|
||||||
- Template dropdown functionality
|
|
||||||
- All existing functionality remains covered
|
|
||||||
Reference in New Issue
Block a user