Veranstaltung melden #290

This commit is contained in:
Daniel Grams 2021-09-07 22:48:11 +02:00
parent efaa666e66
commit 9696a1dee8
24 changed files with 838 additions and 397 deletions

View File

@ -1,3 +1,4 @@
[run]
omit =
project/cli/test.py
project/cli/test.py
project/templates/email/*

View File

@ -77,7 +77,6 @@ describe("Event", () => {
});
it("read and actions", () => {
cy.login();
cy.createAdminUnit().then(function (adminUnitId) {
cy.createEvent(adminUnitId).then(function (eventId) {
cy.visit("/event/" + eventId);
@ -89,6 +88,23 @@ describe("Event", () => {
});
});
it("report", () => {
cy.createAdminUnit().then(function (adminUnitId) {
cy.createEvent(adminUnitId).then(function (eventId) {
cy.visit("/event/" + eventId + "/report");
cy.get("input[name=contactName]").type("Firstname Lastname");
cy.get("input[name=contactEmail]").type("firstname.lastname@test.de");
cy.get("textarea[name=message]").type("Die Veranstaltung kann leider nicht stattfinden.");
cy.screenshot("report");
cy.get("button[type=submit]").click();
cy.get('button[type=submit]').should('not.exist');
cy.screenshot("report-submitted");
});
});
});
it("deletes", () => {
cy.login();
cy.createAdminUnit().then(function (adminUnitId) {

View File

@ -7,7 +7,7 @@ from flask_security.utils import FsPermNeed
from sqlalchemy import and_
from project import app
from project.models import AdminUnit, AdminUnitMember, Event, PublicStatus
from project.models import AdminUnit, AdminUnitMember, Event, PublicStatus, User
from project.services.admin_unit import get_member_for_admin_unit_by_user_id
@ -206,3 +206,17 @@ def can_read_event_or_401(event: Event):
def can_read_private_events(admin_unit: AdminUnit) -> bool:
return has_access(admin_unit, "event:read")
def get_admin_unit_members_with_permission(admin_unit_id: int, permission: str) -> list:
members = (
AdminUnitMember.query.join(User)
.filter(AdminUnitMember.admin_unit_id == admin_unit_id)
.all()
)
return list(
filter(
lambda member: has_admin_unit_member_permission(member, permission), members
)
)

View File

@ -1,4 +1,4 @@
from flask import make_response
from flask import make_response, request
from flask_apispec import doc, marshal_with, use_kwargs
from sqlalchemy.orm import lazyload, load_only
@ -10,12 +10,13 @@ from project.access import (
login_api_user,
login_api_user_or_401,
)
from project.api import add_api_resource
from project.api import add_api_resource, rest_api
from project.api.event.schemas import (
EventListRequestSchema,
EventListResponseSchema,
EventPatchRequestSchema,
EventPostRequestSchema,
EventReportPostSchema,
EventSchema,
EventSearchRequestSchema,
EventSearchResponseSchema,
@ -25,6 +26,7 @@ from project.api.event_date.schemas import (
EventDateListResponseSchema,
)
from project.api.resources import BaseResource
from project.api.schemas import NoneSchema
from project.models import AdminUnit, Event, EventDate, PublicStatus
from project.oauth2 import require_oauth
from project.services.event import (
@ -34,7 +36,10 @@ from project.services.event import (
update_event,
)
from project.services.event_search import EventSearchParams
from project.views.event import send_referenced_event_changed_mails
from project.views.event import (
send_event_report_mails,
send_referenced_event_changed_mails,
)
def api_can_read_event_or_401(event: Event):
@ -154,7 +159,23 @@ class EventSearchResource(BaseResource):
return pagination
class EventReportsResource(BaseResource):
@doc(summary="Add event report", tags=["Events"])
@use_kwargs(EventReportPostSchema, location="json", apply=False)
@marshal_with(NoneSchema, 204)
def post(self, id):
event = Event.query.options(
load_only(Event.id, Event.public_status)
).get_or_404(id)
api_can_read_event_or_401(event)
send_event_report_mails(event, request.json)
return make_response("", 204)
add_api_resource(EventListResource, "/events", "api_v1_event_list")
add_api_resource(EventResource, "/events/<int:id>", "api_v1_event")
add_api_resource(EventDatesResource, "/events/<int:id>/dates", "api_v1_event_dates")
add_api_resource(EventSearchResource, "/events/search", "api_v1_event_search")
rest_api.add_resource(
EventReportsResource, "/events/<int:id>/reports", endpoint="api_v1_event_reports"
)

View File

@ -295,3 +295,17 @@ class EventPatchRequestSchema(
self.make_patch_schema()
photo = Owned(ImagePatchRequestSchema)
class EventReportPostSchema(marshmallow.Schema):
contact_name = fields.Str(
required=True,
validate=validate.Length(min=5, max=255),
)
contact_email = fields.Email(
required=True, validate=[validate.Email(), validate.Length(max=255)]
)
message = fields.Str(
required=True,
validate=validate.Length(min=20, max=1000),
)

View File

@ -1,6 +1,8 @@
from flask_security import hash_password
from sqlalchemy.orm import joinedload
from project import user_datastore
from project.models import Role, User
def create_user(email, password):
@ -52,3 +54,9 @@ def find_user_by_email(email):
def get_user(id):
return user_datastore.find_user(id=id)
def find_all_users_with_role(role_name: str) -> list:
return (
User.query.options(joinedload(User.roles)).filter(Role.name == role_name).all()
)

View File

@ -0,0 +1,53 @@
const ValidatedInput = {
template: `
<div>
<ValidationProvider :vid="vid" :name="$attrs.label" :rules="rules" v-slot="validationContext">
<b-form-group v-bind="$attrs">
<b-form-input
v-model="innerValue"
v-bind="$attrs"
:state="getValidationState(validationContext)"
></b-form-input>
<b-form-invalid-feedback :state="getValidationState(validationContext)">
{{ validationContext.errors[0] }}
</b-form-invalid-feedback>
</b-form-group>
</ValidationProvider>
</div>
`,
props: {
vid: {
type: String
},
rules: {
type: [Object, String],
default: ""
},
value: {
type: null
}
},
data: () => ({
innerValue: ""
}),
watch: {
// Handles internal model changes.
innerValue(newVal) {
this.$emit("input", newVal);
},
// Handles external model changes.
value(newVal) {
this.innerValue = newVal;
}
},
created() {
if (this.value) {
this.innerValue = this.value;
}
},
methods: {
getValidationState({ dirty, validated, valid = null }) {
return dirty || validated ? valid : null;
},
},
};

View File

@ -0,0 +1,53 @@
const ValidatedTextarea = {
template: `
<div>
<ValidationProvider :vid="vid" :name="$attrs.label" :rules="rules" v-slot="validationContext">
<b-form-group v-bind="$attrs">
<b-form-textarea
v-model="innerValue"
v-bind="$attrs"
:state="getValidationState(validationContext)"
></b-form-textarea>
<b-form-invalid-feedback :state="getValidationState(validationContext)">
{{ validationContext.errors[0] }}
</b-form-invalid-feedback>
</b-form-group>
</ValidationProvider>
</div>
`,
props: {
vid: {
type: String
},
rules: {
type: [Object, String],
default: ""
},
value: {
type: null
}
},
data: () => ({
innerValue: ""
}),
watch: {
// Handles internal model changes.
innerValue(newVal) {
this.$emit("input", newVal);
},
// Handles external model changes.
value(newVal) {
this.innerValue = newVal;
}
},
created() {
if (this.value) {
this.innerValue = this.value;
}
},
methods: {
getValidationState({ dirty, validated, valid = null }) {
return dirty || validated ? valid : null;
},
},
};

View File

@ -0,0 +1,116 @@
const EventReportCreate = {
template: `
<div>
<h1>{{ $t("comp.title") }}</h1>
<b-overlay :show="isLoading">
<div>
<h2 v-if="event"><b-link :href="eventUrl">{{ event.name }}</b-link></h2>
<ValidationObserver v-slot="{ handleSubmit }" v-if="!isSubmitted">
<b-form @submit.stop.prevent="handleSubmit(submitForm)">
<validated-input
:label="$t('shared.models.eventReport.contactName')"
:description="$t('shared.models.eventReport.contactNameDescription')"
name="contactName"
v-model="form.contactName"
rules="required|min:5" />
<validated-input
:label="$t('shared.models.eventReport.contactEmail')"
name="contactEmail"
v-model="form.contactEmail"
rules="required|email" />
<validated-textarea
:label="$t('shared.models.eventReport.message')"
:description="$t('shared.models.eventReport.messageDescription')"
name="message"
v-model="form.message"
rules="required|min:20" />
<b-button variant="primary" type="submit" v-bind:disabled="isSubmitting">
<b-spinner small v-if="isSubmitting"></b-spinner>
{{ $t("shared.submit") }}
</b-button>
</b-form>
</ValidationObserver>
</div>
</b-overlay>
</div>
`,
i18n: {
messages: {
en: {
comp: {
title: "Report event",
successMessage: "Event successfully reported",
},
},
de: {
comp: {
title: "Veranstaltung melden",
successMessage: "Veranstaltung erfolgreich gemeldet",
},
},
},
},
data: () => ({
isLoading: false,
isSubmitting: false,
isSubmitted: false,
event: null,
form: {
contactName: "",
contactEmail: "",
message: "",
},
}),
computed: {
eventId() {
return this.$route.params.event_id;
},
eventUrl() {
return `/event/${this.eventId}`;
}
},
mounted() {
this.isLoading = false;
this.event = null;
this.form = {
contactName: "",
contactEmail: "",
message: "",
};
this.isSubmitted = false;
this.loadFormData();
},
methods: {
loadFormData() {
axios
.get(`/api/v1/events/${this.eventId}`, {
handleLoading: this.handleLoading,
})
.then((response) => {
this.event = response.data;
});
},
handleLoading(isLoading) {
this.isLoading = isLoading;
},
getValidationState({ dirty, validated, valid = null }) {
return dirty || validated ? valid : null;
},
submitForm() {
const data = {
name: this.form.name,
};
axios
.post(`/api/v1/events/${this.eventId}/reports`, data, {
handleLoading: this.handleSubmitting,
})
.then(() => {
this.$root.makeSuccessToast(this.$t("comp.successMessage"));
this.isSubmitted = true;
});
},
handleSubmitting(isLoading) {
this.isSubmitting = isLoading;
},
},
};

View File

@ -56,6 +56,8 @@ const OrganizationRelationUpdate = {
},
},
mounted() {
this.isLoading = false;
this.relation = null;
this.form = {
auto_verify_event_reference_requests: false,
};

View File

@ -460,6 +460,7 @@
</div>
<div class="card-footer small">
{{ render_audit(event, show_rating) }}
<a href="{{ url_for('event_report', event_id=event.id) }}" class="text-dark" target="_blank" rel="noopener noreferrer">{{ _('Report event') }}</a>
</div>
</div>
@ -615,6 +616,7 @@
<div class="small mt-2">
{{ render_audit(event, show_rating) }}
<a href="{{ url_for('event_report', event_id=event.id) }}" class="text-dark">{{ _('Report event') }}</a>
</div>
</div>
</div>

View File

@ -0,0 +1,9 @@
{% extends "email/layout.html" %}
{% from "_macros.html" import render_email_button %}
{% block content %}
<p>{{ _('There is a new event report.') }}</p>
<p>{{ report['contact_name'] }}</p>
<p>{{ report['contact_email'] }}</p>
<p>{{ report['message'] }}</p>
{{ render_email_button(url_for('event', event_id=event.id, _external=True), _('Click here to view the event')) }}
{% endblock %}

View File

@ -0,0 +1,6 @@
{{ _('There is a new event report.') }}
{{ _('Click the link below to view the event') }}
{{ report['contact_name'] }}
{{ report['contact_email'] }}
{{ report['message'] }}
{{ url_for('event', event_id=event.id, _external=True) }}

View File

@ -0,0 +1,22 @@
{% extends "layout_vue.html" %}
{%- block title -%}
{{ _('Report event') }}
{%- endblock -%}
{% block component_scripts %}
<script src="{{ url_for('static', filename='vue/event-report/create.vue.js')}}"></script>
{% endblock %}
{% block component_definitions %}
Vue.component("EventReportCreate", EventReportCreate);
{% endblock %}
{% block vue_routes %}
const routes = [
{
path: "/event/:event_id/report",
component: EventReportCreate,
},
];
{% endblock %}

View File

@ -0,0 +1,334 @@
{% extends "layout.html" %}
{% block header_before_site_js %}
<link
type="text/css"
rel="stylesheet"
href="https://unpkg.com/bootstrap-vue@2.21.2/dist/bootstrap-vue.min.css"
/>
{% if False | env_override('FLASK_DEBUG') %}
<script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
{% else %}
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
{% endif %}
<script src="https://unpkg.com/bootstrap-vue@2.21.2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/vue-router@2.0.0/dist/vue-router.min.js"></script>
<script src="https://unpkg.com/vue-i18n@8.25.0/dist/vue-i18n.min.js"></script>
<script src="https://unpkg.com/axios@0.21.1/dist/axios.min.js"></script>
<script src="https://unpkg.com/lodash@4.17.21/lodash.min.js"></script>
<script src="https://unpkg.com/portal-vue@2.1.7/dist/portal-vue.umd.min.js"></script>
<link
href="https://unpkg.com/vue-typeahead-bootstrap@2.12.0/dist/VueTypeaheadBootstrap.css"
rel="stylesheet"
/>
<script src="https://unpkg.com/vue-typeahead-bootstrap@2.12.0/dist/VueTypeaheadBootstrap.umd.min.js"></script>
<script src="https://unpkg.com/vee-validate@3.4.11/dist/vee-validate.full.min.js"></script>
<script src="{{ url_for('static', filename='vue/common/typeahead.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/common/validated-input.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/common/validated-textarea.vue.js')}}"></script>
{% block component_scripts %}
{% endblock %}
{% endblock %}
{% block content %}
<div id="vue-container"><router-view></router-view></div>
<script>
Vue.component("vue-typeahead-bootstrap", VueTypeaheadBootstrap);
Vue.component("ValidationObserver", VeeValidate.ValidationObserver);
Vue.component("ValidationProvider", VeeValidate.ValidationProvider);
Vue.component("custom-typeahead", CustomTypeahead);
Vue.component("validated-input", ValidatedInput);
Vue.component("validated-textarea", ValidatedTextarea);
{% block component_definitions %}
{% endblock %}
const sharedMessages = {
en: {
shared: {
models: {
adminUnitRelation: {
targetOrganization: "Other organization",
autoVerifyEventReferenceRequests: "Verify reference requests automatically",
},
eventReport: {
contactName: "Name",
contactEmail: "Email",
contactNameDescription: "First and last name",
message: "Message",
messageDescription: "Briefly describe in your words why the event is objectionable.",
},
},
cancel: "Cancel",
submit: "Submit",
edit: "Edit",
delete: "Delete",
emptyData: "No data available",
errors: {
uniqueViolation:
"An entry with the entered values already exists. Duplicate entries are not allowed.",
},
toast: {
errorTitle: "Error",
successTitle: "Success",
},
autocomplete: {
instruction: "Type to search",
},
validation: {
alpha: "The {_field_} field may only contain alphabetic characters",
alpha_num:
"The {_field_} field may only contain alpha-numeric characters",
alpha_dash:
"The {_field_} field may contain alpha-numeric characters as well as dashes and underscores",
alpha_spaces:
"The {_field_} field may only contain alphabetic characters as well as spaces",
between: "The {_field_} field must be between {min} and {max}",
confirmed: "The {_field_} field confirmation does not match",
digits:
"The {_field_} field must be numeric and exactly contain {length} digits",
dimensions:
"The {_field_} field must be {width} pixels by {height} pixels",
email: "The {_field_} field must be a valid email",
excluded: "The {_field_} field is not a valid value",
ext: "The {_field_} field is not a valid file",
image: "The {_field_} field must be an image",
integer: "The {_field_} field must be an integer",
length: "The {_field_} field must be {length} long",
max_value: "The {_field_} field must be {max} or less",
max: "The {_field_} field may not be greater than {length} characters",
mimes: "The {_field_} field must have a valid file type",
min_value: "The {_field_} field must be {min} or more",
min: "The {_field_} field must be at least {length} characters",
numeric: "The {_field_} field may only contain numeric characters",
oneOf: "The {_field_} field is not a valid value",
regex: "The {_field_} field format is invalid",
required_if: "The {_field_} field is required",
required: "The {_field_} field is required",
size: "The {_field_} field size must be less than {size}KB",
double: "The {_field_} field must be a valid decimal",
},
},
},
de: {
shared: {
models: {
adminUnitRelation: {
targetOrganization: "Andere Organisation",
autoVerifyEventReferenceRequests:
"Empfehlungsanfragen automatisch verifizieren",
},
eventReport: {
contactName: "Name",
contactNameDescription: "Vor- und Nachname",
contactEmail: "Email-Adresse",
message: "Mitteilung",
messageDescription: "Beschreibe kurz in deinen Worten, warum die Veranstaltung zu beanstanden ist.",
},
},
cancel: "Abbrechen",
submit: "Senden",
edit: "Bearbeiten",
delete: "Löschen",
emptyData: "Keine Daten vorhanden",
errors: {
uniqueViolation:
"Ein Eintrag mit den eingegebenen Werten existiert bereits. Doppelte Einträge sind nicht erlaubt.",
},
toast: {
errorTitle: "Fehler",
successTitle: "Erfolg",
},
autocomplete: {
instruction: "Tippen um zu suchen",
},
validation: {
alpha: "{_field_} darf nur alphabetische Zeichen enthalten",
alpha_dash:
"{_field_} darf alphanumerische Zeichen sowie Striche und Unterstriche enthalten",
alpha_num: "{_field_} darf nur alphanumerische Zeichen enthalten",
alpha_spaces:
"{_field_} darf nur alphanumerische Zeichen und Leerzeichen enthalten",
between: "{_field_} muss zwischen {min} und {max} liegen",
confirmed: "Die Bestätigung von {_field_} stimmt nicht überein",
digits:
"{_field_} muss numerisch sein und exakt {length} Ziffern enthalten",
dimensions: "{_field_} muss {width} x {height} Bildpunkte groß sein",
email: "{_field_} muss eine gültige E-Mail-Adresse sein",
excluded: "{_field_} muss ein gültiger Wert sein",
ext: "{_field_} muss eine gültige Datei sein",
image: "{_field_} muss eine Grafik sein",
oneOf: "{_field_} muss ein gültiger Wert sein",
integer: "{_field_} muss eine ganze Zahl sein",
length: "Die Länge von {_field_} muss {length} sein",
max: "{_field_} darf nicht länger als {length} Zeichen sein",
max_value: "{_field_} darf maximal {max} sein",
mimes: "{_field_} muss einen gültigen Dateityp haben",
min: "{_field_} muss mindestens {length} Zeichen lang sein",
min_value: "{_field_} muss mindestens {min} sein",
numeric: "{_field_} darf nur numerische Zeichen enthalten",
regex: "Das Format von {_field_} ist ungültig",
required: "{_field_} ist ein Pflichtfeld",
required_if: "{_field_} ist ein Pflichtfeld",
size: "{_field_} muss kleiner als {size}KB sein",
double: "Das Feld {_field_} muss eine gültige Dezimalzahl sein",
},
},
},
};
const i18n = new VueI18n({
locale: "de",
messages: sharedMessages,
silentFallbackWarn: true,
});
VeeValidate.configure({
defaultMessage: (field, values) => {
return i18n.t(`shared.validation.${values._rule_}`, values);
}
});
Object.keys(VeeValidate.Rules).forEach((rule) => {
VeeValidate.extend(rule, VeeValidate.Rules[rule]);
});
{% block vue_routes %}
{% endblock %}
const router = new VueRouter({
routes: routes,
mode: "history",
base: "/",
});
axios.defaults.baseURL = "{{ get_base_url() }}";
axios.interceptors.request.use(
function (config) {
if (config) {
this.app.handleAxiosStart(config);
}
return config;
},
function (error) {
this.app.handleAxiosError(error);
return Promise.reject(error);
}
);
axios.interceptors.response.use(
function (response) {
if (response && response.config) {
this.app.handleAxiosFinish(response.config);
}
return response;
},
function (error) {
this.app.handleAxiosError(error);
return Promise.reject(error);
}
);
var app = new Vue({
el: "#vue-container",
i18n,
router: router,
data() {
return {};
},
methods: {
handleAxiosStart(config) {
if (
config &&
config.handler &&
config.handler.hasOwnProperty("handleRequestStart")
) {
config.handler.handleRequestStart();
}
if (
config &&
config.hasOwnProperty("handleLoading")
) {
config.handleLoading(true);
}
},
handleAxiosFinish(config) {
if (
config &&
config.handler &&
config.handler.hasOwnProperty("handleRequestFinish")
) {
config.handler.handleRequestFinish();
}
if (
config &&
config.hasOwnProperty("handleLoading")
) {
config.handleLoading(false);
}
},
handleAxiosError(error) {
if (error && error.config) {
this.handleAxiosFinish(error.config);
}
const status = error && error.response && error.response.status;
let message = error.message || error;
if (status == 400) {
message =
(error &&
error.response &&
error.response.data &&
error.response.data.message) ||
error;
errorName =
error &&
error.response &&
error.response.data &&
error.response.data.name;
if (errorName == "Unique Violation") {
message = this.$t("shared.errors.uniqueViolation");
}
}
if (
error.config &&
error.config.handler &&
error.config.handler.hasOwnProperty("handleRequestError")
) {
error.config.handler.handleRequestError(error, message);
} else {
this.makeErrorToast(message);
}
},
makeErrorToast(message) {
this.makeToast(message, "danger", this.$t("shared.toast.errorTitle"));
},
makeSuccessToast(message) {
this.makeToast(
message,
"success",
this.$t("shared.toast.successTitle")
);
},
makeToast(message, variant, title) {
this.$bvToast.toast(message, {
title: title,
variant: variant,
toaster: "b-toaster-top-center",
noCloseButton: true,
solid: true,
});
},
goBack(fallbackPath) {
window.history.length > 1 ? this.$router.go(-1) : this.$router.push({ path: fallbackPath })
},
},
});
</script>
{% endblock %}

View File

@ -1,330 +1,34 @@
{% extends "layout.html" %}
{% extends "layout_vue.html" %}
{%- block title -%}
{{ _('Relations') }}
{%- endblock -%}
{% block header_before_site_js %}
<link
type="text/css"
rel="stylesheet"
href="https://unpkg.com/bootstrap-vue@2.21.2/dist/bootstrap-vue.min.css"
/>
{% if False | env_override('FLASK_DEBUG') %}
<script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
{% else %}
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
{% endif %}
<script src="https://unpkg.com/bootstrap-vue@2.21.2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/vue-router@2.0.0/dist/vue-router.min.js"></script>
<script src="https://unpkg.com/vue-i18n@8.25.0/dist/vue-i18n.min.js"></script>
<script src="https://unpkg.com/axios@0.21.1/dist/axios.min.js"></script>
<script src="https://unpkg.com/lodash@4.17.21/lodash.min.js"></script>
<script src="https://unpkg.com/portal-vue@2.1.7/dist/portal-vue.umd.min.js"></script>
<link
href="https://unpkg.com/vue-typeahead-bootstrap@2.12.0/dist/VueTypeaheadBootstrap.css"
rel="stylesheet"
/>
<script src="https://unpkg.com/vue-typeahead-bootstrap@2.12.0/dist/VueTypeaheadBootstrap.umd.min.js"></script>
<script src="https://unpkg.com/vee-validate@3.4.11/dist/vee-validate.full.min.js"></script>
{% block component_scripts %}
<script src="{{ url_for('static', filename='vue/organization-relations/list.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/organization-relations/create.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/organization-relations/update.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/common/typeahead.vue.js')}}"></script>
{% endblock %} {% block scripts %} {%- endblock scripts %} {% block content %}
<div id="vue-relations"><router-view></router-view></div>
<script>
Vue.component("vue-typeahead-bootstrap", VueTypeaheadBootstrap);
Vue.component("ValidationObserver", VeeValidate.ValidationObserver);
Vue.component("ValidationProvider", VeeValidate.ValidationProvider);
Vue.component("custom-typeahead", CustomTypeahead);
Vue.component("OrganizationRelationList", OrganizationRelationList);
Vue.component("OrganizationRelationCreate", OrganizationRelationCreate);
Vue.component("OrganizationRelationUpdate", OrganizationRelationUpdate);
const sharedMessages = {
en: {
shared: {
models: {
adminUnitRelation: {
targetOrganization: "Other organization",
autoVerifyEventReferenceRequests: "Verify reference requests automatically",
},
},
cancel: "Cancel",
submit: "Submit",
edit: "Edit",
delete: "Delete",
emptyData: "No data available",
errors: {
uniqueViolation:
"An entry with the entered values already exists. Duplicate entries are not allowed.",
},
toast: {
errorTitle: "Error",
successTitle: "Success",
},
autocomplete: {
instruction: "Type to search",
},
validation: {
alpha: "The {_field_} field may only contain alphabetic characters",
alpha_num:
"The {_field_} field may only contain alpha-numeric characters",
alpha_dash:
"The {_field_} field may contain alpha-numeric characters as well as dashes and underscores",
alpha_spaces:
"The {_field_} field may only contain alphabetic characters as well as spaces",
between: "The {_field_} field must be between {min} and {max}",
confirmed: "The {_field_} field confirmation does not match",
digits:
"The {_field_} field must be numeric and exactly contain {length} digits",
dimensions:
"The {_field_} field must be {width} pixels by {height} pixels",
email: "The {_field_} field must be a valid email",
excluded: "The {_field_} field is not a valid value",
ext: "The {_field_} field is not a valid file",
image: "The {_field_} field must be an image",
integer: "The {_field_} field must be an integer",
length: "The {_field_} field must be {length} long",
max_value: "The {_field_} field must be {max} or less",
max: "The {_field_} field may not be greater than {length} characters",
mimes: "The {_field_} field must have a valid file type",
min_value: "The {_field_} field must be {min} or more",
min: "The {_field_} field must be at least {length} characters",
numeric: "The {_field_} field may only contain numeric characters",
oneOf: "The {_field_} field is not a valid value",
regex: "The {_field_} field format is invalid",
required_if: "The {_field_} field is required",
required: "The {_field_} field is required",
size: "The {_field_} field size must be less than {size}KB",
double: "The {_field_} field must be a valid decimal",
},
},
},
de: {
shared: {
models: {
adminUnitRelation: {
targetOrganization: "Andere Organisation",
autoVerifyEventReferenceRequests:
"Empfehlungsanfragen automatisch verifizieren",
},
},
cancel: "Abbrechen",
submit: "Senden",
edit: "Bearbeiten",
delete: "Löschen",
emptyData: "Keine Daten vorhanden",
errors: {
uniqueViolation:
"Ein Eintrag mit den eingegebenen Werten existiert bereits. Doppelte Einträge sind nicht erlaubt.",
},
toast: {
errorTitle: "Fehler",
successTitle: "Erfolg",
},
autocomplete: {
instruction: "Tippen um zu suchen",
},
validation: {
alpha: "{_field_} darf nur alphabetische Zeichen enthalten",
alpha_dash:
"{_field_} darf alphanumerische Zeichen sowie Striche und Unterstriche enthalten",
alpha_num: "{_field_} darf nur alphanumerische Zeichen enthalten",
alpha_spaces:
"{_field_} darf nur alphanumerische Zeichen und Leerzeichen enthalten",
between: "{_field_} muss zwischen {min} und {max} liegen",
confirmed: "Die Bestätigung von {_field_} stimmt nicht überein",
digits:
"{_field_} muss numerisch sein und exakt {length} Ziffern enthalten",
dimensions: "{_field_} muss {width} x {height} Bildpunkte groß sein",
email: "{_field_} muss eine gültige E-Mail-Adresse sein",
excluded: "{_field_} muss ein gültiger Wert sein",
ext: "{_field_} muss eine gültige Datei sein",
image: "{_field_} muss eine Grafik sein",
oneOf: "{_field_} muss ein gültiger Wert sein",
integer: "{_field_} muss eine ganze Zahl sein",
length: "Die Länge von {_field_} muss {length} sein",
max: "{_field_} darf nicht länger als {length} Zeichen sein",
max_value: "{_field_} darf maximal {max} sein",
mimes: "{_field_} muss einen gültigen Dateityp haben",
min: "{_field_} muss mindestens {length} Zeichen lang sein",
min_value: "{_field_} muss mindestens {min} sein",
numeric: "{_field_} darf nur numerische Zeichen enthalten",
regex: "Das Format von {_field_} ist ungültig",
required: "{_field_} ist ein Pflichtfeld",
required_if: "{_field_} ist ein Pflichtfeld",
size: "{_field_} muss kleiner als {size}KB sein",
double: "Das Feld {_field_} muss eine gültige Dezimalzahl sein",
},
},
},
};
const i18n = new VueI18n({
locale: "de",
messages: sharedMessages,
silentFallbackWarn: true,
});
VeeValidate.configure({
defaultMessage: (field, values) => {
return i18n.t(`shared.validation.${values._rule_}`, values);
}
});
Object.keys(VeeValidate.Rules).forEach((rule) => {
VeeValidate.extend(rule, VeeValidate.Rules[rule]);
});
const routes = [
{
path: "/manage/admin_unit/:admin_unit_id/relations",
component: OrganizationRelationList,
},
{
path: "/manage/admin_unit/:admin_unit_id/relations/create",
component: OrganizationRelationCreate,
},
{
path: "/manage/admin_unit/:admin_unit_id/relations/:relation_id/update",
component: OrganizationRelationUpdate,
},
];
const router = new VueRouter({
routes: routes,
mode: "history",
base: "/",
});
axios.defaults.baseURL = "{{ get_base_url() }}";
axios.interceptors.request.use(
function (config) {
if (config) {
this.app.handleAxiosStart(config);
}
return config;
},
function (error) {
this.app.handleAxiosError(error);
return Promise.reject(error);
}
);
axios.interceptors.response.use(
function (response) {
if (response && response.config) {
this.app.handleAxiosFinish(response.config);
}
return response;
},
function (error) {
this.app.handleAxiosError(error);
return Promise.reject(error);
}
);
var app = new Vue({
el: "#vue-relations",
i18n,
router: router,
data() {
return {};
},
methods: {
handleAxiosStart(config) {
if (
config &&
config.handler &&
config.handler.hasOwnProperty("handleRequestStart")
) {
config.handler.handleRequestStart();
}
if (
config &&
config.hasOwnProperty("handleLoading")
) {
config.handleLoading(true);
}
},
handleAxiosFinish(config) {
if (
config &&
config.handler &&
config.handler.hasOwnProperty("handleRequestFinish")
) {
config.handler.handleRequestFinish();
}
if (
config &&
config.hasOwnProperty("handleLoading")
) {
config.handleLoading(false);
}
},
handleAxiosError(error) {
if (error && error.config) {
this.handleAxiosFinish(error.config);
}
const status = error && error.response && error.response.status;
let message = error.message || error;
if (status == 400) {
message =
(error &&
error.response &&
error.response.data &&
error.response.data.message) ||
error;
errorName =
error &&
error.response &&
error.response.data &&
error.response.data.name;
if (errorName == "Unique Violation") {
message = this.$t("shared.errors.uniqueViolation");
}
}
if (
error.config &&
error.config.handler &&
error.config.handler.hasOwnProperty("handleRequestError")
) {
error.config.handler.handleRequestError(error, message);
} else {
this.makeErrorToast(message);
}
},
makeErrorToast(message) {
this.makeToast(message, "danger", this.$t("shared.toast.errorTitle"));
},
makeSuccessToast(message) {
this.makeToast(
message,
"success",
this.$t("shared.toast.successTitle")
);
},
makeToast(message, variant, title) {
this.$bvToast.toast(message, {
title: title,
variant: variant,
toaster: "b-toaster-top-center",
noCloseButton: true,
solid: true,
});
},
goBack(fallbackPath) {
window.history.length > 1 ? this.$router.go(-1) : this.$router.push({ path: fallbackPath })
},
},
});
</script>
{% endblock %}
{% block component_definitions %}
Vue.component("OrganizationRelationList", OrganizationRelationList);
Vue.component("OrganizationRelationCreate", OrganizationRelationCreate);
Vue.component("OrganizationRelationUpdate", OrganizationRelationUpdate);
{% endblock %}
{% block vue_routes %}
const routes = [
{
path: "/manage/admin_unit/:admin_unit_id/relations",
component: OrganizationRelationList,
},
{
path: "/manage/admin_unit/:admin_unit_id/relations/create",
component: OrganizationRelationCreate,
},
{
path: "/manage/admin_unit/:admin_unit_id/relations/:relation_id/update",
component: OrganizationRelationUpdate,
},
];
{% endblock %}

View File

@ -12,15 +12,14 @@ from project.access import (
can_read_event_or_401,
can_reference_event,
can_request_event_reference,
get_admin_unit_members_with_permission,
has_access,
has_admin_unit_member_permission,
)
from project.dateutils import get_next_full_hour
from project.forms.event import CreateEventForm, DeleteEventForm, UpdateEventForm
from project.jsonld import DateTimeEncoder, get_sd_for_event_date
from project.models import (
AdminUnit,
AdminUnitMember,
Event,
EventCategory,
EventOrganizer,
@ -29,7 +28,6 @@ from project.models import (
EventReviewStatus,
EventSuggestion,
PublicStatus,
User,
)
from project.services.event import (
get_event_with_details_or_404,
@ -48,7 +46,7 @@ from project.views.utils import (
get_share_links,
handleSqlError,
non_match_for_deletion,
send_mail,
send_mails,
)
@ -96,6 +94,14 @@ def event_actions(event_id):
)
@app.route("/event/<int:event_id>/report")
def event_report(event_id):
event = Event.query.get_or_404(event_id)
can_read_event_or_401(event)
return render_template("event/report.html")
@app.route("/admin_unit/<int:id>/events/create", methods=("GET", "POST"))
@auth_required()
def event_create_for_admin_unit_id(id):
@ -380,18 +386,38 @@ def send_referenced_event_changed_mails(event):
for reference in references:
# Alle Mitglieder der AdminUnit, die das Recht haben, Requests zu verifizieren
members = (
AdminUnitMember.query.join(User)
.filter(AdminUnitMember.admin_unit_id == reference.admin_unit_id)
.all()
members = get_admin_unit_members_with_permission(
reference.admin_unit_id, "reference_request:verify"
)
emails = list(map(lambda member: member.user.email, members))
send_mails(
emails,
gettext("Referenced event changed"),
"referenced_event_changed_notice",
event=event,
reference=reference,
)
for member in members:
if has_admin_unit_member_permission(member, "reference_request:verify"):
send_mail(
member.user.email,
gettext("Referenced event changed"),
"referenced_event_changed_notice",
event=event,
reference=reference,
)
def send_event_report_mails(event: Event, report: dict):
from project.services.user import find_all_users_with_role
# Alle Mitglieder der AdminUnit, die das Recht haben, Events zu bearbeiten
members = get_admin_unit_members_with_permission(
event.admin_unit_id, "event:update"
)
emails = list(map(lambda member: member.user.email, members))
# Alle globalen Admins
admins = find_all_users_with_role("admin")
admin_emails = list(map(lambda admin: admin.email, admins))
emails.extend(x for x in admin_emails if x not in emails)
send_mails(
emails,
gettext("New event report"),
"event_report_notice",
event=event,
report=report,
)

View File

@ -8,16 +8,14 @@ from project import app, db
from project.access import (
access_or_401,
get_admin_unit_for_manage_or_404,
get_admin_unit_members_with_permission,
get_admin_units_for_event_reference_request,
has_admin_unit_member_permission,
)
from project.forms.reference_request import CreateEventReferenceRequestForm
from project.models import (
AdminUnitMember,
Event,
EventReferenceRequest,
EventReferenceRequestReviewStatus,
User,
)
from project.services.admin_unit import get_admin_unit_relation
from project.services.reference import (
@ -28,7 +26,7 @@ from project.views.utils import (
flash_errors,
get_pagination_urls,
handleSqlError,
send_mail,
send_mails,
)
@ -130,15 +128,12 @@ def send_member_reference_request_verify_mails(
admin_unit_id, subject, template, **context
):
# Benachrichtige alle Mitglieder der AdminUnit, die Requests verifizieren können
members = (
AdminUnitMember.query.join(User)
.filter(AdminUnitMember.admin_unit_id == admin_unit_id)
.all()
members = get_admin_unit_members_with_permission(
admin_unit_id, "reference_request:verify"
)
emails = list(map(lambda member: member.user.email, members))
for member in members:
if has_admin_unit_member_permission(member, "reference_request:verify"):
send_mail(member.user.email, subject, template, **context)
send_mails(emails, subject, template, **context)
def send_reference_request_inbox_mails(request):

View File

@ -4,22 +4,24 @@ from flask_security import auth_required
from sqlalchemy.exc import SQLAlchemyError
from project import app, db
from project.access import access_or_401, has_access, has_admin_unit_member_permission
from project.access import (
access_or_401,
get_admin_unit_members_with_permission,
has_access,
)
from project.dateutils import get_today
from project.forms.reference_request import ReferenceRequestReviewForm
from project.models import (
AdminUnitMember,
EventDate,
EventReferenceRequest,
EventReferenceRequestReviewStatus,
User,
)
from project.services.admin_unit import (
get_admin_unit_relation,
upsert_admin_unit_relation,
)
from project.services.reference import create_event_reference_for_request
from project.views.utils import flash_errors, handleSqlError, send_mail
from project.views.utils import flash_errors, handleSqlError, send_mails
@app.route("/reference_request/<int:id>/review", methods=("GET", "POST"))
@ -123,17 +125,14 @@ def event_reference_request_review_status(id):
def send_reference_request_review_status_mails(request):
# Benachrichtige alle Mitglieder der AdminUnit, die diesen Request erstellt hatte
members = (
AdminUnitMember.query.join(User)
.filter(AdminUnitMember.admin_unit_id == request.event.admin_unit_id)
.all()
members = get_admin_unit_members_with_permission(
request.event.admin_unit_id, "reference_request:create"
)
emails = list(map(lambda member: member.user.email, members))
for member in members:
if has_admin_unit_member_permission(member, "reference_request:create"):
send_mail(
member.user.email,
gettext("Event review status updated"),
"reference_request_review_status_notice",
request=request,
)
send_mails(
emails,
gettext("Event review status updated"),
"reference_request_review_status_notice",
request=request,
)

View File

@ -121,11 +121,17 @@ def send_mail(recipient, subject, template, **context):
def send_mails(recipients, subject, template, **context):
if len(recipients) == 0: # pragma: no cover
return
msg = Message(subject)
msg.recipients = recipients
msg.body = render_template("email/%s.txt" % template, **context)
msg.html = render_template("email/%s.html" % template, **context)
send_mail_message(msg)
def send_mail_message(msg):
if not mail.default_sender:
app.logger.info(",".join(msg.recipients))
app.logger.info(msg.subject)

View File

@ -10,20 +10,13 @@ from project import app, db
from project.access import (
admin_unit_suggestions_enabled_or_404,
can_read_event_or_401,
has_admin_unit_member_permission,
get_admin_unit_members_with_permission,
)
from project.dateutils import get_next_full_hour
from project.forms.event_date import FindEventDateForm
from project.forms.event_suggestion import CreateEventSuggestionForm
from project.jsonld import DateTimeEncoder, get_sd_for_event_date
from project.models import (
AdminUnit,
AdminUnitMember,
EventOrganizer,
EventReviewStatus,
EventSuggestion,
User,
)
from project.models import AdminUnit, EventOrganizer, EventReviewStatus, EventSuggestion
from project.services.event import (
get_event_date_with_details_or_404,
get_event_dates_query,
@ -40,7 +33,7 @@ from project.views.utils import (
get_pagination_urls,
get_share_links,
handleSqlError,
send_mail,
send_mails,
)
@ -211,17 +204,12 @@ def get_styles(admin_unit):
def send_event_inbox_mails(admin_unit, event_suggestion):
members = (
AdminUnitMember.query.join(User)
.filter(AdminUnitMember.admin_unit_id == admin_unit.id)
.all()
)
members = get_admin_unit_members_with_permission(admin_unit.id, "event:verify")
emails = list(map(lambda member: member.user.email, members))
for member in members:
if has_admin_unit_member_permission(member, "event:verify"):
send_mail(
member.user.email,
gettext("New event review"),
"review_notice",
event_suggestion=event_suggestion,
)
send_mails(
emails,
gettext("New event review"),
"review_notice",
event_suggestion=event_suggestion,
)

View File

@ -528,3 +528,31 @@ def test_delete(client, seeder, utils, app):
event = Event.query.get(event_id)
assert event is None
def test_report_mail(client, seeder, utils, app, mocker):
user_id, admin_unit_id = seeder.setup_base(admin=False, log_in=False)
event_id = seeder.create_event(admin_unit_id)
seeder.create_user(email="admin@test.de", admin=True)
mail_mock = utils.mock_send_mails(mocker)
url = utils.get_url("api_v1_event_reports", id=event_id)
response = utils.post_json(
url,
{
"contact_name": "Firstname Lastname",
"contact_email": "firstname.lastname@test.de",
"message": "Diese Veranstaltung wird nicht stattfinden.",
},
)
utils.assert_response_no_content(response)
utils.assert_send_mail_called(
mail_mock,
["test@test.de", "admin@test.de"],
[
"Firstname Lastname",
"firstname.lastname@test.de",
"Diese Veranstaltung wird nicht stattfinden.",
],
)

View File

@ -175,12 +175,28 @@ class UtilActions(object):
)
def mock_send_mails(self, mocker):
return mocker.patch("project.views.utils.send_mails")
return mocker.patch("project.views.utils.send_mail_message")
def assert_send_mail_called(self, mock, recipient):
def assert_send_mail_called(self, mock, recipients, contents=None):
mock.assert_called_once()
args, kwargs = mock.call_args
assert args[0] == [recipient]
message = args[0]
if not isinstance(recipients, list):
recipients = [recipients]
for recipient in recipients:
assert recipient in message.recipients, f"{recipient} not in recipients"
if contents:
if not isinstance(contents, list):
contents = [contents]
for content in contents:
assert content in message.body, f"{content} not in body"
assert content in message.html, f"{content} not in html"
return message
def mock_now(self, mocker, year, month, day):
from project.dateutils import create_berlin_date

View File

@ -481,3 +481,11 @@ def test_rrule(client, seeder, utils, app):
occurence = json["occurrences"][0]
assert occurence["date"] == "20201125T000000"
assert occurence["formattedDate"] == '"25.11.2020"'
def test_report(seeder, utils):
user_id, admin_unit_id = seeder.setup_base()
event_id = seeder.create_event(admin_unit_id)
url = utils.get_url("event_report", event_id=event_id)
utils.get_ok(url)