Skip to content

Commit cb6f048

Browse files
authored
Feature/event upload image (#294)
1 parent 32e3a74 commit cb6f048

File tree

12 files changed

+118
-16
lines changed

12 files changed

+118
-16
lines changed

app/config.py.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ PSQL_ENVIRONMENT = False
2727
MEDIA_DIRECTORY = 'media'
2828
PICTURE_EXTENSION = '.png'
2929
AVATAR_SIZE = (120, 120)
30+
# For security reasons, set the upload path to a local absolute path.
31+
# Or for testing environment - just specify a folder name
32+
# that will be created under /app/
33+
UPLOAD_DIRECTORY = 'event_images'
3034

3135

3236
# DEFAULT WEBSITE LANGUAGE

app/database/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class Event(Base):
103103
invitees = Column(String)
104104
privacy = Column(String, default=PrivacyKinds.Public.name, nullable=False)
105105
emotion = Column(String, nullable=True)
106+
image = Column(String, nullable=True)
106107
availability = Column(Boolean, default=True, nullable=False)
107108

108109
owner_id = Column(Integer, ForeignKey("users.id"))

app/dependencies.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,27 @@
1515
TEMPLATES_PATH = os.path.join(APP_PATH, "templates")
1616
SOUNDS_PATH = os.path.join(STATIC_PATH, "tracks")
1717
templates = Jinja2Templates(directory=TEMPLATES_PATH)
18-
templates.env.add_extension('jinja2.ext.i18n')
18+
templates.env.add_extension("jinja2.ext.i18n")
1919

2020
# Configure logger
21-
logger = LoggerCustomizer.make_logger(config.LOG_PATH,
22-
config.LOG_FILENAME,
23-
config.LOG_LEVEL,
24-
config.LOG_ROTATION_INTERVAL,
25-
config.LOG_RETENTION_INTERVAL,
26-
config.LOG_FORMAT)
21+
logger = LoggerCustomizer.make_logger(
22+
config.LOG_PATH,
23+
config.LOG_FILENAME,
24+
config.LOG_LEVEL,
25+
config.LOG_ROTATION_INTERVAL,
26+
config.LOG_RETENTION_INTERVAL,
27+
config.LOG_FORMAT,
28+
)
29+
30+
if os.path.isdir(config.UPLOAD_DIRECTORY):
31+
UPLOAD_PATH = config.UPLOAD_DIRECTORY
32+
else:
33+
try:
34+
UPLOAD_PATH = os.path.join(os.getcwd(), config.UPLOAD_DIRECTORY)
35+
os.mkdir(UPLOAD_PATH)
36+
except OSError as e:
37+
logger.critical(e)
38+
raise OSError(e)
2739

2840

2941
def get_db() -> Session:

app/locales/en/LC_MESSAGES/base.po

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,3 @@ msgstr ""
130130

131131
#~ msgid "Agenda"
132132
#~ msgstr ""
133-

app/locales/he/LC_MESSAGES/base.po

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,3 @@ msgstr "בדיקת תרגום בפייתון"
130130

131131
#~ msgid "Agenda"
132132
#~ msgstr ""
133-

app/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
MEDIA_PATH,
1313
SOUNDS_PATH,
1414
STATIC_PATH,
15+
UPLOAD_PATH,
1516
get_db,
1617
logger,
1718
templates,
@@ -39,6 +40,11 @@ def create_tables(engine, psql_environment):
3940
app = FastAPI(title="Pylander", docs_url=None)
4041
app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static")
4142
app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media")
43+
app.mount(
44+
"/event_images",
45+
StaticFiles(directory=UPLOAD_PATH),
46+
name="event_images",
47+
)
4248
app.mount("/static/tracks", StaticFiles(directory=SOUNDS_PATH), name="sounds")
4349
app.logger = logger
4450

app/routers/event.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import io
12
import json
23
import urllib
34
from datetime import datetime as dt
45
from operator import attrgetter
56
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
67

7-
from fastapi import APIRouter, Depends, HTTPException, Request
8+
from fastapi import APIRouter, Depends, File, HTTPException, Request
9+
from PIL import Image
810
from pydantic import BaseModel
911
from sqlalchemy.exc import SQLAlchemyError
1012
from sqlalchemy.orm import Session
@@ -15,6 +17,7 @@
1517
from starlette.responses import RedirectResponse, Response
1618
from starlette.templating import _TemplateResponse
1719

20+
from app.config import PICTURE_EXTENSION
1821
from app.database.models import (
1922
Comment,
2023
Event,
@@ -23,7 +26,7 @@
2326
User,
2427
UserEvent,
2528
)
26-
from app.dependencies import get_db, logger, templates
29+
from app.dependencies import UPLOAD_PATH, get_db, logger, templates
2730
from app.internal import comment as cmt
2831
from app.internal.emotion import get_emotion
2932
from app.internal.event import (
@@ -37,6 +40,7 @@
3740
from app.internal.utils import create_model, get_current_user
3841
from app.routers.categories import get_user_categories
3942

43+
IMAGE_HEIGHT = 200
4044
EVENT_DATA = Tuple[Event, List[Dict[str, str]], str]
4145
TIME_FORMAT = "%Y-%m-%d %H:%M"
4246
START_FORMAT = "%A, %d/%m/%Y %H:%M"
@@ -119,6 +123,7 @@ async def eventedit(
119123
@router.post("/edit", include_in_schema=False)
120124
async def create_new_event(
121125
request: Request,
126+
event_img: bytes = File(None),
122127
session=Depends(get_db),
123128
) -> Response:
124129
data = await request.form()
@@ -180,6 +185,11 @@ async def create_new_event(
180185
privacy=privacy,
181186
)
182187

188+
if event_img:
189+
image = process_image(event_img, event.id)
190+
event.image = image
191+
session.commit()
192+
183193
messages = get_messages(session, event, uninvited_contacts)
184194
return RedirectResponse(
185195
router.url_path_for("eventview", event_id=event.id)
@@ -188,6 +198,31 @@ async def create_new_event(
188198
)
189199

190200

201+
def process_image(
202+
img: bytes,
203+
event_id: int,
204+
img_height: int = IMAGE_HEIGHT,
205+
) -> str:
206+
"""Resized and saves picture without exif (to avoid malicious date))
207+
according to required height and keep aspect ratio"""
208+
try:
209+
image = Image.open(io.BytesIO(img))
210+
except IOError:
211+
error_message = "The uploaded file is not a valid image"
212+
logger.exception(error_message)
213+
return
214+
width, height = image.size
215+
height_to_req_height = img_height / float(height)
216+
new_width = int(float(width) * float(height_to_req_height))
217+
resized = image.resize((new_width, img_height), Image.ANTIALIAS)
218+
file_name = f"{event_id}{PICTURE_EXTENSION}"
219+
image_data = list(resized.getdata())
220+
image_without_exif = Image.new(resized.mode, resized.size)
221+
image_without_exif.putdata(image_data)
222+
image_without_exif.save(f"{UPLOAD_PATH}/{file_name}")
223+
return file_name
224+
225+
191226
def get_waze_link(event: Event) -> str:
192227
"""Get a waze navigation link to the event location.
193228
@@ -447,6 +482,7 @@ def create_event(
447482
is_google_event: bool = False,
448483
shared_list: Optional[SharedList] = None,
449484
privacy: str = PrivacyKinds.Public.name,
485+
image: Optional[str] = None,
450486
):
451487
"""Creates an event and an association."""
452488

@@ -473,6 +509,7 @@ def create_event(
473509
shared_list=shared_list,
474510
availability=availability,
475511
is_google_event=is_google_event,
512+
image=image,
476513
)
477514
create_model(db, UserEvent, user_id=owner_id, event_id=event.id)
478515
return event

app/templates/eventedit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<title>Event edit</title>
1111
</head>
1212
<body>
13-
<form name="eventeditform" method="POST">
13+
<form name="eventeditform" method="POST" enctype="multipart/form-data">
1414
<!-- Temporary nav layout based on bootstrap -->
1515
<ul class="nav nav-tabs" id="event_edit_nav" role="tablist">
1616
<li class="nav-item">

app/templates/partials/calendar/event/edit_event_details_tab.html

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@
7878
<input type="text" id="invited" name="invited" placeholder="Invited emails, separated by commas">
7979
</div>
8080

81+
<div class="form_row">
82+
<label for="event_img">Image: </label>
83+
<input type="file" id="event_img" name="event_img"
84+
accept="image/png, image/jpeg">
85+
</div>
86+
8187
<div class="form_row textarea">
8288
<textarea id="say" name="description" placeholder="Description"></textarea>
8389
</div>
@@ -95,8 +101,8 @@
95101
<div class="form_row_end">
96102
<label for="event_type">All-day:</label>
97103
<select id="event_type" name="event_type" required>
98-
<option value="on">Yes</option>
99-
<option value="off" selected>No</option>
104+
<option value="on">Yes</option>
105+
<option value="off" selected>No</option>
100106
</select>
101107

102108
<label for="availability">Availability:</label>

app/templates/partials/calendar/event/view_event_details_tab.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ <h1>{{ event.title }}</h1>
77
<!-- <span class="icon">PRIVACY</span>-->
88
</div>
99
</div>
10+
11+
<div class="event-image-container">
12+
<img class="event-image" src="{{ '' if not event.image else url_for('media', path=event.image) }}">
13+
</div>
14+
1015
<div class="event-info-row">
1116
<span class="icon">ICON</span>
1217
<time datetime="{{ event.start }}">{{ event.start.strftime(start_format )}}</time>

schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@
4747
├── test_categories.py
4848
├── test_email.py
4949
├── test_event.py
50-
└── test_profile.py
50+
└── test_profile.py

tests/test_event.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import json
2+
import os
23
from datetime import datetime, timedelta
34

45
import pytest
56
from fastapi import HTTPException, Request
67
from fastapi.testclient import TestClient
8+
from PIL import Image
79
from sqlalchemy.orm.session import Session
810
from sqlalchemy.sql.elements import Null
911
from starlette import status
1012

13+
from app.config import PICTURE_EXTENSION
1114
from app.database.models import Comment, Event
12-
from app.dependencies import get_db
15+
from app.dependencies import UPLOAD_PATH, get_db
1316
from app.internal.privacy import PrivacyKinds
1417
from app.internal.utils import delete_instance
1518
from app.main import app
@@ -539,6 +542,36 @@ def test_deleting_an_event_does_not_exist(event_test_client, event):
539542
assert response.status_code == status.HTTP_404_NOT_FOUND
540543

541544

545+
def test_event_with_image(event_test_client, client, session):
546+
img = Image.new("RGB", (60, 30), color="red")
547+
img.save("pil_red.png")
548+
with open("pil_red.png", "rb") as img:
549+
imgstr = img.read()
550+
files = {"event_img": imgstr}
551+
data = {**CORRECT_EVENT_FORM_DATA}
552+
response = event_test_client.post(
553+
client.app.url_path_for("create_new_event"),
554+
data=data,
555+
files=files,
556+
)
557+
event_created = session.query(Event).order_by(Event.id.desc()).first()
558+
event_id = event_created.id
559+
is_event_image = f"{event_id}{PICTURE_EXTENSION}" == event_created.image
560+
assert response.ok
561+
assert (
562+
client.app.url_path_for("eventview", event_id=event_id).strip(
563+
f"{event_id}",
564+
)
565+
in response.headers["location"]
566+
)
567+
assert is_event_image is True
568+
event_image_path = os.path.join(UPLOAD_PATH, event_created.image)
569+
os.remove(event_image_path)
570+
os.remove("pil_red.png")
571+
session.delete(event_created)
572+
session.commit()
573+
574+
542575
def test_can_show_event_public(event, session, user):
543576
assert event_to_show(event, session) == event
544577
assert event_to_show(event, session, user) == event

0 commit comments

Comments
 (0)