diff --git a/.gitignore b/.gitignore index b3a358a..b4693c7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,6 @@ ENV/ *.sqlite3 # Logs and databases -*.log *.sql *.sqlite @@ -46,3 +45,4 @@ ENV/ .Trashes ehthumbs.db Thumbs.db +/static/uploads/ diff --git a/app.py b/app.py index 11e2013..e71605f 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,29 @@ -from flask import Flask, jsonify, request, Response, render_template +import os +import uuid +from flask import Flask, jsonify, request, Response, render_template, current_app, url_for +from werkzeug.utils import secure_filename -from models.pydantic.models import AnimalCreate, AnimalResponse -from typing import Union +from models.pydantic.models import AnimalResponse, AnimalCreate +from typing import Union, Optional from settings import settings from database import init_db from models.sqlalchemy.models import Animal +from flask_migrate import Migrate +from datetime import datetime app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = settings.sqlalchemy_database_uri +# Upload configuration +UPLOAD_FOLDER = 'static/uploads' +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) + + db = init_db(app) +migrate = Migrate(app, db) @app.route('/') @@ -17,6 +31,11 @@ def home() -> str: return render_template('home.html') +@app.route('/health') +def health() -> tuple[Response, int]: + return jsonify({"status": "ok"}), 200 + + @app.route('/animals', methods=['GET']) def index() -> Response: animals = Animal.query.all() @@ -25,39 +44,73 @@ def index() -> Response: @app.route('/animal', methods=['POST']) def add_animal() -> tuple[Response, int]: - data = AnimalCreate(**request.get_json()) - new_animal = Animal( - animal_type=data.animal_type, - name=data.name, - birth_date=data.birth_date - ) + try: + animal_data = AnimalCreate( + animal_type=request.form.get('animal_type'), + name=request.form.get('name'), + birth_date=datetime.strptime(request.form.get('birth_date', ''), '%Y-%m-%d').date(), + breed=request.form.get('breed'), + photo_url=None + ) + except ValueError as e: + return jsonify({"message": str(e)}), 400 + + photo_result = _handle_photo_upload() + if isinstance(photo_result, tuple): # Error response + return photo_result + + if photo_result: + animal_data.photo_url = photo_result + + new_animal = Animal(**animal_data.model_dump()) db.session.add(new_animal) db.session.commit() - return jsonify( - { - "message": "Animal added successfully!", - "animal": AnimalResponse.model_validate(new_animal).model_dump(mode='json') - } - ), 201 + + return jsonify({ + "message": "Animal added successfully!", + "animal": AnimalResponse.model_validate(new_animal).model_dump(mode='json') + }), 201 @app.route('/animal/', methods=['PUT']) def update_animal(pk: int) -> Union[Response, tuple[Response, int]]: - data = AnimalCreate(**request.get_json()) animal = Animal.query.get(pk) if not animal: return jsonify({"message": "Animal not found"}), 404 - animal.animal_type = data.animal_type - animal.name = data.name - animal.birth_date = data.birth_date - db.session.commit() - return jsonify( - { + try: + update_data = { + 'animal_type': request.form.get('animal_type', animal.animal_type), + 'name': request.form.get('name', animal.name), + 'birth_date': animal.birth_date, + 'breed': request.form.get('breed', animal.breed), + 'photo_url': animal.photo_url + } + + if birth_date_str := request.form.get('birth_date'): + update_data['birth_date'] = datetime.strptime(birth_date_str, '%Y-%m-%d').date() + + updated_animal_data = AnimalCreate(**update_data) + + if 'photo' in request.files: + photo_result = _handle_photo_upload(animal.photo_url) + if isinstance(photo_result, tuple): # Error response + return photo_result + if photo_result: + updated_animal_data.photo_url = photo_result + + for field, value in updated_animal_data.model_dump().items(): + setattr(animal, field, value) + + db.session.commit() + + return jsonify({ "message": "Animal updated successfully!", - "animal": AnimalResponse.model_validate(animal).model_dump(mode='json'), - }, - ) + "animal": AnimalResponse.model_validate(animal).model_dump(mode='json') + }) + + except ValueError as e: + return jsonify({"message": str(e)}), 400 @app.route('/animal/', methods=['GET']) @@ -79,16 +132,57 @@ def delete_animal(pk: int) -> Union[Response, tuple[Response, int]]: if not animal: return jsonify({"message": "Animal not found"}), 404 + if animal.photo_url: + _delete_old_photo(animal.photo_url) + db.session.delete(animal) db.session.commit() return jsonify({"message": "Animal deleted successfully!"}) -def initialize_app(): - with app.app_context(): - db.create_all() +def allowed_file(filename: str) -> bool: + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +def _handle_photo_upload(current_photo_url: Optional[str] = None) -> Union[str, tuple[Response, int], None]: + if 'photo' not in request.files: + return None + + file = request.files['photo'] + if not file or not file.filename: + return None + + if not allowed_file(file.filename): + return jsonify({"message": "File type not allowed"}), 400 + + try: + if current_photo_url: + _delete_old_photo(current_photo_url) + + unique_filename = str(uuid.uuid4()) + "_" + secure_filename(file.filename) + file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename) + file.save(file_path) + + uploaded_file_url = f"/{os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)}" + return uploaded_file_url + + except Exception as e: + current_app.logger.error(f"Failed to save file: {e}") + return jsonify({"message": "Failed to save photo"}), 500 + + +def _delete_old_photo(photo_url: str) -> None: + old_photo_path_relative = photo_url.lstrip('/') + old_photo_path_absolute = os.path.join(os.getcwd(), old_photo_path_relative) + + if os.path.exists(old_photo_path_absolute): + try: + os.remove(old_photo_path_absolute) + current_app.logger.info(f"Deleted old photo: {old_photo_path_absolute}") + except OSError as e: + current_app.logger.error(f"Error deleting old photo {old_photo_path_absolute}: {e}") if __name__ == '__main__': - initialize_app() - app.run(debug=True) + app.run(debug=True) \ No newline at end of file diff --git a/migrations/versions/b432cb1d0393_add_breed_and_photo_url_to_animal_table.py b/migrations/versions/b432cb1d0393_add_breed_and_photo_url_to_animal_table.py new file mode 100644 index 0000000..d68cd54 --- /dev/null +++ b/migrations/versions/b432cb1d0393_add_breed_and_photo_url_to_animal_table.py @@ -0,0 +1,34 @@ +"""add breed and photo_url to animal table + +Revision ID: b432cb1d0393 +Revises: e08fc0218f8b +Create Date: 2025-06-08 18:47:50.046990 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b432cb1d0393' +down_revision = 'e08fc0218f8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('animal', schema=None) as batch_op: + batch_op.add_column(sa.Column('breed', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('photo_url', sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('animal', schema=None) as batch_op: + batch_op.drop_column('photo_url') + batch_op.drop_column('breed') + + # ### end Alembic commands ### diff --git a/models/pydantic/models.py b/models/pydantic/models.py index 91e66db..a74ba80 100644 --- a/models/pydantic/models.py +++ b/models/pydantic/models.py @@ -1,11 +1,14 @@ from datetime import date -from pydantic import BaseModel, ConfigDict +from typing import Optional +from pydantic import BaseModel, ConfigDict, computed_field class AnimalCreate(BaseModel): animal_type: str name: str birth_date: date + breed: Optional[str] = None + photo_url: Optional[str] = None class AnimalResponse(BaseModel): @@ -15,3 +18,24 @@ class AnimalResponse(BaseModel): animal_type: str name: str birth_date: date + breed: Optional[str] = None + photo_url: Optional[str] = None + + @computed_field + def age(self) -> str: + today = date.today() + years = today.year - self.birth_date.year + months = today.month - self.birth_date.month + + if today.day < self.birth_date.day: + months -= 1 + if months < 0: + years -= 1 + months += 12 + + years_str = f"{years} year{'s' if years != 1 else ''}" + months_str = f"{months} month{'s' if months != 1 else ''}" + if months == 0: + return years_str + else: + return f"{years_str} {months_str}" diff --git a/models/sqlalchemy/models.py b/models/sqlalchemy/models.py index dc6e14a..fb2d503 100644 --- a/models/sqlalchemy/models.py +++ b/models/sqlalchemy/models.py @@ -9,3 +9,5 @@ class Animal(db.Model): animal_type = db.Column(db.String, nullable=False) name = db.Column(db.String, nullable=False) birth_date = db.Column(db.Date, nullable=False) + breed = db.Column(db.String, nullable=True) + photo_url = db.Column(db.String, nullable=True) diff --git a/pyproject.toml b/pyproject.toml index 156df2c..83caf77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "" authors = ["marcustas "] readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = "^3.11" @@ -13,7 +14,6 @@ flask-sqlalchemy = "^3.1.1" flask-migrate = "^4.0.5" pydantic-settings = "^2.0.3" - [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/templates/home.html b/templates/home.html index 80b942e..5d45b18 100644 --- a/templates/home.html +++ b/templates/home.html @@ -33,6 +33,14 @@

Animals in the Shelter

+
+ + +
+
+ + +
@@ -45,7 +53,10 @@

List of animals:

Animal Type Name + Age Birth Date + Breed + Photo Actions @@ -80,6 +91,15 @@ +
+ + +
+
+ + +
+