Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ ENV/
*.sqlite3

# Logs and databases
*.log
*.sql
*.sqlite

Expand All @@ -46,3 +45,4 @@ ENV/
.Trashes
ehthumbs.db
Thumbs.db
/static/uploads/
154 changes: 124 additions & 30 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,41 @@
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('/')
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()
Expand 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/<int:pk>', 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/<int:pk>', methods=['GET'])
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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 ###
26 changes: 25 additions & 1 deletion models/pydantic/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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}"
2 changes: 2 additions & 0 deletions models/sqlalchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
description = ""
authors = ["marcustas <stas.chernov@casafari.com>"]
readme = "README.md"
package-mode = false

[tool.poetry.dependencies]
python = "^3.11"
Expand All @@ -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"
Loading