Issue/199 (#201)

API: Add image support #199
This commit is contained in:
Daniel Grams 2021-06-24 15:52:03 +02:00 committed by GitHub
parent 238f581baa
commit 868b0e34c9
23 changed files with 103 additions and 100 deletions

View File

@ -10,6 +10,7 @@ from project.api.event_category.schemas import (
)
from project.api.fields import CustomDateTimeField, Owned
from project.api.image.schemas import (
ImageDumpSchema,
ImagePatchRequestSchema,
ImagePostRequestSchema,
ImageSchema,
@ -162,7 +163,7 @@ class EventDumpSchema(EventIdSchema, EventBaseSchemaMixin):
organization_id = fields.Int(attribute="admin_unit_id")
organizer_id = fields.Int()
place_id = fields.Int(attribute="event_place_id")
photo_id = fields.Int()
photo = fields.Nested(ImageDumpSchema)
category_ids = fields.Pluck(
EventCategoryIdSchema, "id", many=True, attribute="categories"
)

View File

@ -1,7 +1,7 @@
from marshmallow import ValidationError, fields, post_load, validates_schema
from project.api import marshmallow
from project.api.schemas import IdSchemaMixin, SQLAlchemyBaseSchema
from project.api.schemas import SQLAlchemyBaseSchema
from project.imageutils import (
get_bytes_from_image,
get_image_from_base64_str,
@ -10,6 +10,7 @@ from project.imageutils import (
resize_image_to_max,
validate_image,
)
from project.jinja_filters import url_for_image
from project.models import Image
@ -19,23 +20,19 @@ class ImageModelSchema(SQLAlchemyBaseSchema):
load_instance = True
class ImageIdSchema(ImageModelSchema, IdSchemaMixin):
pass
class ImageBaseSchemaMixin(object):
copyright_text = marshmallow.auto_field()
class ImageSchema(ImageIdSchema, ImageBaseSchemaMixin):
image_url = marshmallow.URLFor(
"image",
values=dict(id="<id>", s=500),
metadata={
"description": "Append query arguments w for width, h for height or s for size(width and height)."
},
class ImageSchema(ImageModelSchema, ImageBaseSchemaMixin):
image_url = fields.Method(
"get_image_url",
metadata={"description": "Image URL. Append query argument s for size."},
)
def get_image_url(self, image):
return url_for_image(image)
class ImageDumpSchema(ImageModelSchema, ImageBaseSchemaMixin):
pass

View File

@ -1,8 +1,8 @@
from marshmallow import fields
from project.api import marshmallow
from project.api.image.schemas import ImageSchema
from project.api.location.schemas import LocationSchema
from project.api.image.schemas import ImageDumpSchema, ImageSchema
from project.api.location.schemas import LocationDumpSchema, LocationSchema
from project.api.schemas import PaginationRequestSchema, PaginationResponseSchema
from project.models import AdminUnit
@ -32,8 +32,8 @@ class OrganizationSchema(OrganizationBaseSchema):
class OrganizationDumpSchema(OrganizationBaseSchema):
location_id = fields.Int()
logo_id = fields.Int()
location = fields.Nested(LocationDumpSchema)
logo = fields.Nested(ImageDumpSchema)
class OrganizationRefSchema(OrganizationIdSchema):

View File

@ -2,8 +2,9 @@ from marshmallow import fields, validate
from project.api import marshmallow
from project.api.fields import Owned
from project.api.image.schemas import ImageSchema
from project.api.image.schemas import ImageDumpSchema, ImageSchema
from project.api.location.schemas import (
LocationDumpSchema,
LocationPatchRequestSchema,
LocationPostRequestSchema,
LocationSchema,
@ -53,8 +54,8 @@ class OrganizerSchema(OrganizerIdSchema, OrganizerBaseSchemaMixin):
class OrganizerDumpSchema(OrganizerIdSchema, OrganizerBaseSchemaMixin):
location_id = fields.Int()
logo_id = fields.Int()
location = fields.Nested(LocationDumpSchema)
logo = fields.Nested(ImageDumpSchema)
organization_id = fields.Int(attribute="admin_unit_id")

View File

@ -2,8 +2,9 @@ from marshmallow import fields, validate
from project.api import marshmallow
from project.api.fields import Owned
from project.api.image.schemas import ImageSchema
from project.api.image.schemas import ImageDumpSchema, ImageSchema
from project.api.location.schemas import (
LocationDumpSchema,
LocationPatchRequestSchema,
LocationPostRequestSchema,
LocationSchema,
@ -50,8 +51,8 @@ class PlaceSchema(PlaceIdSchema, PlaceBaseSchemaMixin):
class PlaceDumpSchema(PlaceIdSchema, PlaceBaseSchemaMixin):
location_id = fields.Int()
photo_id = fields.Int()
location = fields.Nested(LocationDumpSchema)
photo = fields.Nested(ImageDumpSchema)
organization_id = fields.Int(attribute="admin_unit_id")

View File

@ -10,8 +10,6 @@ from project import app, dump_path
from project.api.event.schemas import EventDumpSchema
from project.api.event_category.schemas import EventCategoryDumpSchema
from project.api.event_reference.schemas import EventReferenceDumpSchema
from project.api.image.schemas import ImageDumpSchema
from project.api.location.schemas import LocationDumpSchema
from project.api.organization.schemas import OrganizationDumpSchema
from project.api.organizer.schemas import OrganizerDumpSchema
from project.api.place.schemas import PlaceDumpSchema
@ -22,8 +20,6 @@ from project.models import (
EventOrganizer,
EventPlace,
EventReference,
Image,
Location,
)
from project.utils import make_dir
@ -54,10 +50,6 @@ def dump_all():
places = EventPlace.query.all()
dump_items(places, PlaceDumpSchema(many=True), "places", tmp_path)
# Locations
locations = Location.query.all()
dump_items(locations, LocationDumpSchema(many=True), "locations", tmp_path)
# Event categories
event_categories = EventCategory.query.all()
dump_items(
@ -71,10 +63,6 @@ def dump_all():
organizers = EventOrganizer.query.all()
dump_items(organizers, OrganizerDumpSchema(many=True), "organizers", tmp_path)
# Images
images = Image.query.all()
dump_items(images, ImageDumpSchema(many=True), "images", tmp_path)
# Organizations
organizations = AdminUnit.query.all()
dump_items(

View File

@ -44,6 +44,20 @@ app.jinja_env.filters["place_str"] = lambda p: get_place_str(p)
app.jinja_env.filters["location_str"] = lambda l: get_location_str(l)
def get_base_url():
return url_for("home", _external=True).rstrip("/")
def url_for_image(image, **values):
return url_for("image", id=image.id, hash=image.get_hash(), **values)
app.jinja_env.globals.update(
get_base_url=get_base_url,
url_for_image=url_for_image,
)
@app.context_processor
def get_context_processors():
def get_manage_menu_options(admin_unit):
@ -66,9 +80,6 @@ def get_context_processors():
"reference_requests_incoming_badge": reference_requests_incoming_badge,
}
def get_base_url():
return url_for("home", _external=True).rstrip("/")
return dict(
get_manage_menu_options=get_manage_menu_options, get_base_url=get_base_url
get_manage_menu_options=get_manage_menu_options,
)

View File

@ -4,6 +4,7 @@ from json import JSONEncoder
from flask import url_for
from project.dateutils import berlin_tz
from project.jinja_filters import url_for_image
from project.models import EventAttendanceMode, EventStatus
@ -81,7 +82,7 @@ def get_sd_for_place(place, use_ref=True):
result["geo"] = get_sd_for_geo(place.location)
if place.photo_id:
result["photo"] = url_for("image", id=place.photo_id)
result["photo"] = url_for_image(place.photo)
if place.url:
result["url"] = place.url
@ -157,6 +158,6 @@ def get_sd_for_event_date(event_date):
result["eventStatus"] = "EventRescheduled"
if event.photo_id:
result["image"] = url_for("image", id=event.photo_id)
result["image"] = url_for_image(event.photo)
return result

View File

@ -33,6 +33,7 @@ from sqlalchemy.schema import CheckConstraint
from sqlalchemy_utils import ColorType
from project import db
from project.dateutils import gmt_tz
from project.dbtypes import IntegerEnum
from project.utils import make_check_violation
@ -129,6 +130,13 @@ class Image(db.Model, TrackableMixin):
def is_empty(self):
return not self.data
def get_hash(self):
return (
int(self.updated_at.replace(tzinfo=gmt_tz).timestamp() * 1000)
if self.updated_at
else 0
)
# User

View File

@ -15,6 +15,7 @@ from project.dateutils import (
dates_from_recurrence_rule,
get_today,
)
from project.jinja_filters import url_for_image
from project.models import (
AdminUnit,
Event,
@ -385,7 +386,7 @@ def get_meta_data(event: Event, event_date: EventDate = None) -> dict:
meta["description"] = desc_short
if event.photo_id:
meta["image"] = url_for("image", id=event.photo_id, _external=True)
meta["image"] = url_for_image(event.photo, _external=True)
return meta

File diff suppressed because one or more lines are too long

View File

@ -293,7 +293,7 @@
{% endmacro %}
{% macro render_img_src(image, size=500) %}
<img src="{{ url_for('image', id=image.id, s=size) }}" class="{{ kwargs['class'] or 'img-fluid' }}" style="{{ kwargs['style'] or 'max-width:100%;' }}" alt="{{ kwargs['alt'] or 'image' }}" />
<img src="{{ url_for_image(image, s=size) }}" class="{{ kwargs['class'] or 'img-fluid' }}" style="{{ kwargs['style'] or 'max-width:100%;' }}" alt="{{ kwargs['alt'] or 'image' }}" />
{% endmacro %}
{% macro render_image(image, size=500) %}
@ -535,7 +535,7 @@
</div>
<h1 class="font-weight-bold my-1">{{ event.name }}{{ render_event_warning_pills(event) }}</h1>
<div class="text-muted">{{ event.event_place.name }}</div>
<div class="text-muted">{{ event.event_place.name }}{% if event.event_place.location and event.event_place.location.city %}, {{ event.event_place.location.city }}{% endif %}</div>
</div>
</div>
@ -1109,29 +1109,6 @@ if (URL) {
</div>
{% endmacro %}
{% macro render_base_image_form(form_field) %}
{{ form_field.hidden_tag() }}
{% if form_field.object_data and form_field.object_data.id %}
<div>
<a href="{{ url_for('image', id=form_field.object_data.id) }}" target="_blank" rel="noopener noreferrer"><img src="{{ url_for('image', id=form_field.object_data.id) }}" class="img-fluid" style="max-width:5rem;" /></a>
</div>
{{ render_field_with_errors(form_field.delete_flag, style="width: fit-content; flex: initial;") }}
{% endif %}
{{ render_field_with_errors(form_field.image_file) }}
{{ render_field_with_errors(form_field.copyright_text) }}
{% endmacro %}
{% macro render_base_image_form_section(form_field) %}
<div class="card mb-4">
<div class="card-header">
{{ form_field.label() }}
</div>
<div class="card-body">
{{ render_base_image_form(form_field) }}
</div>
</div>
{% endmacro %}
{% macro render_event_menu(user_rights, event) %}
{% if user_rights|any_dict_value_true %}
<div class="dropdown my-1">

View File

@ -67,7 +67,7 @@
</div>
<div class="col-sm-4 text-right">
{% if date.event.photo_id %}
<img src="{{ url_for('image', id=date.event.photo_id, s=200) }}" style="object-fit: cover; width: 200px;" />
<img src="{{ url_for_image(date.event.photo, s=200) }}" style="object-fit: cover; width: 200px;" />
{% endif %}
</div>
</div>
@ -82,7 +82,7 @@
<div class="card">
<div>
{% if date.event.photo_id %}
<img src="{{ url_for('image', id=date.event.photo_id, s=500) }}" class="card-img-top" style="object-fit: cover; height: 20vh;" />
<img src="{{ url_for_image(date.event.photo, s=500) }}" class="card-img-top" style="object-fit: cover; height: 20vh;" />
{% endif %}
</div>
<div class="card-body">

View File

@ -1,5 +1,5 @@
{% extends "layout.html" %}
{% from "_macros.html" import render_widget_styles, render_logo, render_cropper_code, render_crop_image_form, render_jquery_steps_header, render_cropper_header, render_base_image_form, render_radio_buttons, render_datepicker_js, render_field_with_errors, render_field %}
{% from "_macros.html" import render_widget_styles, render_logo, render_cropper_code, render_crop_image_form, render_jquery_steps_header, render_cropper_header, render_radio_buttons, render_datepicker_js, render_field_with_errors, render_field %}
{%- block title -%}
{{ _('Create event suggestion') }}
{%- endblock -%}
@ -116,7 +116,7 @@
<div class="card mb-4">
<div class="card-body">
{% if admin_unit.logo_id %}
<div class="mb-4 text-center"><img src="{{ url_for('image', id=admin_unit.logo_id, s=100) }}" class="img-fluid" style="max-height:10vmin;" /></div>
<div class="mb-4 text-center"><img src="{{ url_for_image(admin_unit.logo, s=100) }}" class="img-fluid" style="max-height:10vmin;" /></div>
{% endif %}
<p class="card-text">
Hier kannst du als Gast eine Veranstaltung vorschlagen, die anschließend durch <strong>{{ admin_unit.name }}</strong> geprüft wird.

View File

@ -22,7 +22,7 @@
<div class="row h-100">
<div class="col-auto h-100">
{% if admin_unit.logo_id %}
<img src="{{ url_for('image', id=admin_unit.logo_id) }}" class="img-fluid"
<img src="{{ url_for_image(admin_unit.logo) }}" class="img-fluid"
style="max-height:10vmin;" />
{% endif %}
</div>
@ -49,7 +49,7 @@
<div class="row">
{% if date.event.photo_id %}
<div class="col-auto h-100" style="padding:1rem">
<img src="{{ url_for('image', id=date.event.photo_id) }}" class="img-fluid" style="object-fit: cover; max-width:20vmin;" />
<img src="{{ url_for_image(date.event.photo) }}" class="img-fluid" style="object-fit: cover; max-width:20vmin;" />
</div>
{% endif %}
<div class="col" style="padding:1rem">

View File

@ -11,10 +11,11 @@ from project.utils import make_dir
@app.route("/image/<int:id>")
def image(id):
image = Image.query.options(load_only(Image.id, Image.encoding_format)).get_or_404(
id
)
@app.route("/image/<int:id>/<hash>")
def image(id, hash=None):
image = Image.query.options(
load_only(Image.id, Image.encoding_format, Image.updated_at)
).get_or_404(id)
# Dimensions
width = 500
@ -26,7 +27,8 @@ def image(id):
# Generate file name
extension = image.encoding_format.split("/")[-1] if image.encoding_format else "png"
file_path = os.path.join(img_path, f"{id}-{width}-{height}.{extension}")
hash = image.get_hash()
file_path = os.path.join(img_path, f"{id}-{hash}-{width}-{height}.{extension}")
# Load from disk if exists
if os.path.exists(file_path):

View File

@ -30,7 +30,9 @@ def test_list(client, seeder, utils):
def test_search(client, seeder, utils):
user_id, admin_unit_id = seeder.setup_base()
seeder.create_event(admin_unit_id)
event_id = seeder.create_event(admin_unit_id)
image_id = seeder.upsert_default_image()
seeder.assign_image_to_event(event_id, image_id)
url = utils.get_url("api_v1_event_search")
utils.get_ok(url)
@ -430,13 +432,7 @@ def test_patch_photo_copyright(client, db, seeder, utils, app):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event(admin_unit_id)
image_id = seeder.upsert_default_image()
with app.app_context():
from project.models import Event
event = Event.query.get(event_id)
event.photo_id = image_id
db.session.commit()
seeder.assign_image_to_event(event_id, image_id)
url = utils.get_url("api_v1_event", id=event_id)
response = utils.patch_json(
@ -458,13 +454,7 @@ def test_patch_photo_delete(client, db, seeder, utils, app):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event(admin_unit_id)
image_id = seeder.upsert_default_image()
with app.app_context():
from project.models import Event
event = Event.query.get(event_id)
event.photo_id = image_id
db.session.commit()
seeder.assign_image_to_event(event_id, image_id)
url = utils.get_url("api_v1_event", id=event_id)
response = utils.patch_json(

View File

@ -2,7 +2,7 @@ def test_clear_images(client, seeder, app, utils):
user_id, admin_unit_id = seeder.setup_base()
image_id = seeder.upsert_default_image()
url = utils.get_url("image", id=image_id)
url = utils.get_image_url_for_id(image_id)
utils.get_ok(url)
runner = app.test_cli_runner()

View File

@ -268,6 +268,14 @@ class Seeder(object):
return image_id
def assign_image_to_event(self, event_id, image_id):
with self._app.app_context():
from project.models import Event
event = Event.query.get(event_id)
event.photo_id = image_id
self._db.session.commit()
def create_event_suggestion(self, admin_unit_id, free_text=False):
from project.models import EventSuggestion
from project.services.event import upsert_event_category

View File

@ -132,9 +132,10 @@ def test_update_event_dates_with_recurrence_rule_exdate(
assert event_date.end == create_berlin_date(2021, 6, 9, 18, 0)
def test_get_meta_data(seeder, app):
def test_get_meta_data(seeder, app, db):
user_id, admin_unit_id = seeder.setup_base()
event_id = seeder.create_event(admin_unit_id)
photo_id = seeder.upsert_default_image()
with app.app_context():
from project.models import Event, EventAttendanceMode, Location
@ -147,7 +148,8 @@ def test_get_meta_data(seeder, app):
location.city = "Stadt"
event.event_place.location = location
event.photo_id = 1
event.photo_id = photo_id
db.session.commit()
with app.test_request_context():
meta = get_meta_data(event)

View File

@ -66,7 +66,7 @@ def test_get_sd_for_place(client, app, db, utils, seeder):
with app.test_request_context():
result = get_sd_for_place(place)
assert result["photo"] == utils.get_url("image", id=photo.id)
assert result["photo"] == utils.get_image_url(photo)
assert result["url"] == "http://www.goslar.de"
assert result["address"]["streetAddress"] == "Markt 7"
assert result["address"]["postalCode"] == "38640"
@ -133,7 +133,7 @@ def test_get_sd_for_event_date(client, app, db, seeder, utils):
assert result["url"][0] == utils.get_url("event_date", id=event_date.id)
assert result["url"][1] == "www.goslar.de"
assert result["url"][2] == "www.tickets.de"
assert result["image"] == utils.get_url("image", id=photo.id)
assert result["image"] == utils.get_image_url(photo)
@pytest.mark.parametrize(

View File

@ -171,6 +171,21 @@ class UtilActions(object):
url = url_for(endpoint, **values, _external=False)
return url
def get_image_url(self, image, **values):
from project.jinja_filters import url_for_image
with self._app.test_request_context():
url = url_for_image(image, **values, _external=False)
return url
def get_image_url_for_id(self, image_id, **values):
from project.models import Image
with self._app.app_context():
image = Image.query.get(image_id)
url = self.get_image_url(image, **values)
return url
def get(self, url):
return self._client.get(url)

View File

@ -12,6 +12,6 @@ def test_read(app, seeder, utils, size):
shutil.rmtree(img_path, ignore_errors=True)
url = utils.get_url("image", id=image_id, s=size)
url = utils.get_image_url_for_id(image_id, s=size)
utils.get_ok(url)
utils.get_ok(url) # cache