Skip to content

Commit

Permalink
Feature/event upload image (#294)
Browse files Browse the repository at this point in the history
  • Loading branch information
imimouni committed Feb 24, 2021
1 parent 32e3a74 commit cb6f048
Show file tree
Hide file tree
Showing 12 changed files with 118 additions and 16 deletions.
4 changes: 4 additions & 0 deletions app/config.py.example
Expand Up @@ -27,6 +27,10 @@ PSQL_ENVIRONMENT = False
MEDIA_DIRECTORY = 'media'
PICTURE_EXTENSION = '.png'
AVATAR_SIZE = (120, 120)
# For security reasons, set the upload path to a local absolute path.
# Or for testing environment - just specify a folder name
# that will be created under /app/
UPLOAD_DIRECTORY = 'event_images'


# DEFAULT WEBSITE LANGUAGE
Expand Down
1 change: 1 addition & 0 deletions app/database/models.py
Expand Up @@ -103,6 +103,7 @@ class Event(Base):
invitees = Column(String)
privacy = Column(String, default=PrivacyKinds.Public.name, nullable=False)
emotion = Column(String, nullable=True)
image = Column(String, nullable=True)
availability = Column(Boolean, default=True, nullable=False)

owner_id = Column(Integer, ForeignKey("users.id"))
Expand Down
26 changes: 19 additions & 7 deletions app/dependencies.py
Expand Up @@ -15,15 +15,27 @@
TEMPLATES_PATH = os.path.join(APP_PATH, "templates")
SOUNDS_PATH = os.path.join(STATIC_PATH, "tracks")
templates = Jinja2Templates(directory=TEMPLATES_PATH)
templates.env.add_extension('jinja2.ext.i18n')
templates.env.add_extension("jinja2.ext.i18n")

# Configure logger
logger = LoggerCustomizer.make_logger(config.LOG_PATH,
config.LOG_FILENAME,
config.LOG_LEVEL,
config.LOG_ROTATION_INTERVAL,
config.LOG_RETENTION_INTERVAL,
config.LOG_FORMAT)
logger = LoggerCustomizer.make_logger(
config.LOG_PATH,
config.LOG_FILENAME,
config.LOG_LEVEL,
config.LOG_ROTATION_INTERVAL,
config.LOG_RETENTION_INTERVAL,
config.LOG_FORMAT,
)

if os.path.isdir(config.UPLOAD_DIRECTORY):
UPLOAD_PATH = config.UPLOAD_DIRECTORY
else:
try:
UPLOAD_PATH = os.path.join(os.getcwd(), config.UPLOAD_DIRECTORY)
os.mkdir(UPLOAD_PATH)
except OSError as e:
logger.critical(e)
raise OSError(e)


def get_db() -> Session:
Expand Down
1 change: 0 additions & 1 deletion app/locales/en/LC_MESSAGES/base.po
Expand Up @@ -130,4 +130,3 @@ msgstr ""

#~ msgid "Agenda"
#~ msgstr ""

1 change: 0 additions & 1 deletion app/locales/he/LC_MESSAGES/base.po
Expand Up @@ -130,4 +130,3 @@ msgstr "בדיקת תרגום בפייתון"

#~ msgid "Agenda"
#~ msgstr ""

6 changes: 6 additions & 0 deletions app/main.py
Expand Up @@ -12,6 +12,7 @@
MEDIA_PATH,
SOUNDS_PATH,
STATIC_PATH,
UPLOAD_PATH,
get_db,
logger,
templates,
Expand Down Expand Up @@ -39,6 +40,11 @@ def create_tables(engine, psql_environment):
app = FastAPI(title="Pylander", docs_url=None)
app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static")
app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media")
app.mount(
"/event_images",
StaticFiles(directory=UPLOAD_PATH),
name="event_images",
)
app.mount("/static/tracks", StaticFiles(directory=SOUNDS_PATH), name="sounds")
app.logger = logger

Expand Down
41 changes: 39 additions & 2 deletions app/routers/event.py
@@ -1,10 +1,12 @@
import io
import json
import urllib
from datetime import datetime as dt
from operator import attrgetter
from typing import Any, Dict, List, NamedTuple, Optional, Tuple

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, File, HTTPException, Request
from PIL import Image
from pydantic import BaseModel
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
Expand All @@ -15,6 +17,7 @@
from starlette.responses import RedirectResponse, Response
from starlette.templating import _TemplateResponse

from app.config import PICTURE_EXTENSION
from app.database.models import (
Comment,
Event,
Expand All @@ -23,7 +26,7 @@
User,
UserEvent,
)
from app.dependencies import get_db, logger, templates
from app.dependencies import UPLOAD_PATH, get_db, logger, templates
from app.internal import comment as cmt
from app.internal.emotion import get_emotion
from app.internal.event import (
Expand All @@ -37,6 +40,7 @@
from app.internal.utils import create_model, get_current_user
from app.routers.categories import get_user_categories

IMAGE_HEIGHT = 200
EVENT_DATA = Tuple[Event, List[Dict[str, str]], str]
TIME_FORMAT = "%Y-%m-%d %H:%M"
START_FORMAT = "%A, %d/%m/%Y %H:%M"
Expand Down Expand Up @@ -119,6 +123,7 @@ async def eventedit(
@router.post("/edit", include_in_schema=False)
async def create_new_event(
request: Request,
event_img: bytes = File(None),
session=Depends(get_db),
) -> Response:
data = await request.form()
Expand Down Expand Up @@ -180,6 +185,11 @@ async def create_new_event(
privacy=privacy,
)

if event_img:
image = process_image(event_img, event.id)
event.image = image
session.commit()

messages = get_messages(session, event, uninvited_contacts)
return RedirectResponse(
router.url_path_for("eventview", event_id=event.id)
Expand All @@ -188,6 +198,31 @@ async def create_new_event(
)


def process_image(
img: bytes,
event_id: int,
img_height: int = IMAGE_HEIGHT,
) -> str:
"""Resized and saves picture without exif (to avoid malicious date))
according to required height and keep aspect ratio"""
try:
image = Image.open(io.BytesIO(img))
except IOError:
error_message = "The uploaded file is not a valid image"
logger.exception(error_message)
return
width, height = image.size
height_to_req_height = img_height / float(height)
new_width = int(float(width) * float(height_to_req_height))
resized = image.resize((new_width, img_height), Image.ANTIALIAS)
file_name = f"{event_id}{PICTURE_EXTENSION}"
image_data = list(resized.getdata())
image_without_exif = Image.new(resized.mode, resized.size)
image_without_exif.putdata(image_data)
image_without_exif.save(f"{UPLOAD_PATH}/{file_name}")
return file_name


def get_waze_link(event: Event) -> str:
"""Get a waze navigation link to the event location.
Expand Down Expand Up @@ -447,6 +482,7 @@ def create_event(
is_google_event: bool = False,
shared_list: Optional[SharedList] = None,
privacy: str = PrivacyKinds.Public.name,
image: Optional[str] = None,
):
"""Creates an event and an association."""

Expand All @@ -473,6 +509,7 @@ def create_event(
shared_list=shared_list,
availability=availability,
is_google_event=is_google_event,
image=image,
)
create_model(db, UserEvent, user_id=owner_id, event_id=event.id)
return event
Expand Down
2 changes: 1 addition & 1 deletion app/templates/eventedit.html
Expand Up @@ -10,7 +10,7 @@
<title>Event edit</title>
</head>
<body>
<form name="eventeditform" method="POST">
<form name="eventeditform" method="POST" enctype="multipart/form-data">
<!-- Temporary nav layout based on bootstrap -->
<ul class="nav nav-tabs" id="event_edit_nav" role="tablist">
<li class="nav-item">
Expand Down
Expand Up @@ -78,6 +78,12 @@
<input type="text" id="invited" name="invited" placeholder="Invited emails, separated by commas">
</div>

<div class="form_row">
<label for="event_img">Image: </label>
<input type="file" id="event_img" name="event_img"
accept="image/png, image/jpeg">
</div>

<div class="form_row textarea">
<textarea id="say" name="description" placeholder="Description"></textarea>
</div>
Expand All @@ -95,8 +101,8 @@
<div class="form_row_end">
<label for="event_type">All-day:</label>
<select id="event_type" name="event_type" required>
<option value="on">Yes</option>
<option value="off" selected>No</option>
<option value="on">Yes</option>
<option value="off" selected>No</option>
</select>

<label for="availability">Availability:</label>
Expand Down
Expand Up @@ -7,6 +7,11 @@ <h1>{{ event.title }}</h1>
<!-- <span class="icon">PRIVACY</span>-->
</div>
</div>

<div class="event-image-container">
<img class="event-image" src="{{ '' if not event.image else url_for('media', path=event.image) }}">
</div>

<div class="event-info-row">
<span class="icon">ICON</span>
<time datetime="{{ event.start }}">{{ event.start.strftime(start_format )}}</time>
Expand Down
2 changes: 1 addition & 1 deletion schema.md
Expand Up @@ -47,4 +47,4 @@
├── test_categories.py
├── test_email.py
├── test_event.py
└── test_profile.py
└── test_profile.py
35 changes: 34 additions & 1 deletion tests/test_event.py
@@ -1,15 +1,18 @@
import json
import os
from datetime import datetime, timedelta

import pytest
from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.elements import Null
from starlette import status

from app.config import PICTURE_EXTENSION
from app.database.models import Comment, Event
from app.dependencies import get_db
from app.dependencies import UPLOAD_PATH, get_db
from app.internal.privacy import PrivacyKinds
from app.internal.utils import delete_instance
from app.main import app
Expand Down Expand Up @@ -539,6 +542,36 @@ def test_deleting_an_event_does_not_exist(event_test_client, event):
assert response.status_code == status.HTTP_404_NOT_FOUND


def test_event_with_image(event_test_client, client, session):
img = Image.new("RGB", (60, 30), color="red")
img.save("pil_red.png")
with open("pil_red.png", "rb") as img:
imgstr = img.read()
files = {"event_img": imgstr}
data = {**CORRECT_EVENT_FORM_DATA}
response = event_test_client.post(
client.app.url_path_for("create_new_event"),
data=data,
files=files,
)
event_created = session.query(Event).order_by(Event.id.desc()).first()
event_id = event_created.id
is_event_image = f"{event_id}{PICTURE_EXTENSION}" == event_created.image
assert response.ok
assert (
client.app.url_path_for("eventview", event_id=event_id).strip(
f"{event_id}",
)
in response.headers["location"]
)
assert is_event_image is True
event_image_path = os.path.join(UPLOAD_PATH, event_created.image)
os.remove(event_image_path)
os.remove("pil_red.png")
session.delete(event_created)
session.commit()


def test_can_show_event_public(event, session, user):
assert event_to_show(event, session) == event
assert event_to_show(event, session, user) == event
Expand Down

0 comments on commit cb6f048

Please sign in to comment.