Liste von Organisationen anzeigen #322

This commit is contained in:
Daniel Grams 2021-11-03 19:17:34 +01:00
parent 651f4cd28a
commit 7591dc7748
22 changed files with 371 additions and 70 deletions

View File

@ -22,8 +22,8 @@ describe("Root", () => {
it("example", () => {
cy.createAdminUnit("test@test.de", "Goslar").then(function (adminUnitId) {
cy.createEvent(adminUnitId).then(function (eventId) {
cy.visit("/example");
cy.screenshot("example");
cy.visit("/organizations");
cy.screenshot("organizations");
});
});
});

View File

@ -183,6 +183,7 @@ from project.views import (
oauth,
oauth2_client,
oauth2_token,
organization,
organizer,
planing,
reference,

View File

@ -199,6 +199,17 @@ def can_create_admin_unit():
return any(admin_unit.can_create_other for admin_unit in admin_units)
def can_verify_admin_unit():
if not current_user.is_authenticated: # pragma: no cover
return False
if has_current_user_role("admin"):
return True
admin_units = get_admin_units_for_manage()
return any(admin_unit.can_verify_other for admin_unit in admin_units)
def can_read_event(event: Event) -> bool:
if event.public_status == PublicStatus.published and event.admin_unit.is_verified:
return True

View File

@ -5,7 +5,9 @@ from sqlalchemy import and_
from project import db
from project.access import (
access_or_401,
can_verify_admin_unit,
get_admin_unit_for_manage_or_404,
login_api_user,
login_api_user_or_401,
)
from project.api import add_api_resource
@ -166,9 +168,14 @@ class OrganizationListResource(BaseResource):
@doc(summary="List organizations", tags=["Organizations"])
@use_kwargs(OrganizationListRequestSchema, location=("query"))
@marshal_with(OrganizationListResponseSchema)
@require_oauth(optional=True)
def get(self, **kwargs):
keyword = kwargs["keyword"] if "keyword" in kwargs else None
pagination = get_admin_unit_query(keyword).paginate()
login_api_user()
include_unverified = can_verify_admin_unit()
pagination = get_admin_unit_query(keyword, include_unverified).paginate()
return pagination

View File

@ -37,6 +37,7 @@ class OrganizationBaseSchema(OrganizationIdSchema):
email = marshmallow.auto_field()
phone = marshmallow.auto_field()
fax = marshmallow.auto_field()
is_verified = fields.Boolean()
class OrganizationSchema(OrganizationBaseSchema):

View File

@ -14,7 +14,6 @@ from project.models import (
)
from project.services.image import upsert_image_with_data
from project.services.location import assign_location_values
from project.utils import strings_are_equal_ignoring_case
def insert_admin_unit_for_user(admin_unit, user, invitation=None):
@ -59,18 +58,12 @@ def insert_admin_unit_for_user(admin_unit, user, invitation=None):
relation = upsert_admin_unit_relation(invitation.admin_unit_id, admin_unit.id)
relation.invited = True
name_equals_suggested_name = strings_are_equal_ignoring_case(
admin_unit.name, invitation.admin_unit_name
)
relation.auto_verify_event_reference_requests = (
inviting_admin_unit.incoming_reference_requests_allowed
and invitation.relation_auto_verify_event_reference_requests
and name_equals_suggested_name
)
relation.verify = (
inviting_admin_unit.can_verify_other
and invitation.relation_verify
and name_equals_suggested_name
inviting_admin_unit.can_verify_other and invitation.relation_verify
)
db.session.commit()
@ -173,9 +166,12 @@ def get_admin_unit_member(id):
return AdminUnitMember.query.filter_by(id=id).first()
def get_admin_unit_query(keyword=None):
def get_admin_unit_query(keyword=None, include_unverified=False):
query = AdminUnit.query
if not include_unverified:
query = query.filter(AdminUnit.is_verified)
if keyword:
like_keyword = "%" + keyword + "%"
keyword_filter = or_(

View File

@ -62,12 +62,12 @@ const CustomTypeahead = {
fetchData(query) {
const vm = this
axios
.get(this.fetchURL.replace('{query}', escape(query)))
.get(this.fetchURL.replace('{query}', query))
.then(response => {
vm.suggestions = response.data.items
})
},
fetchDataDebounced: _.debounce(function(query) { this.fetchData(query) }, 1000),
fetchDataDebounced: _.debounce(function(query) { this.fetchData(query) }, 500),
onInput() {
this.selected = null;
this.fetchDataDebounced(this.query)

View File

@ -17,7 +17,7 @@ const OrganizationOrganizationInvitationCreate = {
name="organizationName"
v-model="form.organization_name"
rules="required|uniqueOrganizationName"
:debounce="1000" />
:debounce="500" />
<validated-switch
v-if="adminUnit.can_verify_other"
:label="$t('shared.models.adminUnitInvitation.relationVerify')"

View File

@ -12,7 +12,7 @@ const OrganizationOrganizationInvitationUpdate = {
name="organizationName"
v-model="form.organization_name"
rules="required|uniqueOrganizationName"
:debounce="1000" />
:debounce="500" />
<validated-switch
v-if="adminUnit.can_verify_other"
:label="$t('shared.models.adminUnitInvitation.relationVerify')"

View File

@ -0,0 +1,101 @@
const OrganizationList = {
template: `
<div>
<h1>{{ $t("shared.models.adminUnit.listName") }}</h1>
<div class="alert alert-danger" role="alert" v-if="errorMessage">
{{ errorMessage }}
</div>
<b-form-group>
<b-form-input
id="filter-input"
v-model="filter"
type="search"
:placeholder="$t('shared.autocomplete.instruction')"
debounce="500"
></b-form-input>
</b-form-group>
<b-table
ref="table"
id="main-table"
:fields="fields"
:items="loadTableData"
:filter="filter"
:current-page="currentPage"
:per-page="perPage"
primary-key="id"
thead-class="d-none"
outlined
hover
responsive
show-empty
:empty-text="$t('shared.emptyData')"
:empty-filtered-text="$t('shared.emptyData')"
style="min-height:100px"
>
<template #cell(name)="data">
<b-link :to="{ name: 'OrganizationById', params: { organization_id: data.item.id } }">
<div>{{ data.item.name }}</div>
<div class="text-muted">@{{ data.item.short_name }}</div>
</b-link>
</template>
</b-table>
<b-pagination v-if="totalRows > 0"
v-model="currentPage"
:total-rows="totalRows"
:per-page="perPage"
aria-controls="main-table"
></b-pagination>
</div>
`,
data: () => ({
errorMessage: null,
fields: [
{
key: "name",
label: i18n.t("shared.models.adminUnit.name"),
},
],
filter: null,
totalRows: 0,
currentPage: 1,
perPage: 10,
searchResult: {
items: [],
},
}),
methods: {
loadTableData(ctx, callback) {
const vm = this;
axios
.get(`/api/v1/organizations`, {
params: {
page: ctx.currentPage,
per_page: ctx.perPage,
keyword: ctx.filter,
},
withCredentials: true,
handler: this,
})
.then((response) => {
vm.totalRows = response.data.total;
callback(response.data.items);
})
.catch(() => {
callback([]);
});
return null;
},
refreshTableData() {
this.$refs.table.refresh();
},
handleRequestStart() {
this.errorMessage = null;
},
handleRequestError(error, message) {
this.errorMessage = message;
},
},
};

View File

@ -0,0 +1,83 @@
const OrganizationRead = {
template: `
<div>
<b-overlay :show="isLoading">
<div v-if="organization">
<b-row class="mb-5">
<b-col v-if="organization.logo" cols="12" sm="auto">
<figure class="figure mx-5">
<b-img :src="organization.logo.image_url + '?s=120'" class="figure-img img-fluid" style="max-width:120px;" alt="Logo"></b-img>
</figure>
</b-col>
<b-col cols="12" sm>
<h1 class="my-0">{{ organization.name }} <template v-if="organization.is_verified"><i class="fa fa-check-circle text-primary"></i></template></h1>
<div class="mb-3 text-muted">@{{ organization.short_name }}</div>
<div v-if="organization.url">
<i class="fa fa-fw fa-link"></i>
<a :href="organization.url" target="_blank" rel="noopener noreferrer" style="word-break: break-all;">{{ organization.url }}</a>
</div>
<div v-if="organization.email">
<i class="fa fa-fw fa-envelope"></i>
<a :href="'mailto:' + organization.email">{{ organization.email }}</a>
</div>
<div v-if="organization.phone">
<i class="fa fa-fw fa-phone"></i>
<a :href="'tel:' + organization.phone">{{ organization.phone }}</a>
</div>
<div v-if="organization.fax">
<i class="fa fa-fw fa-fax"></i>
{{ organization.fax }}
</div>
<div v-if="organization.location && (organization.location.street || organization.location.postalCode || organization.location.city)">
<i class="fa fa-fw fa-map-marker"></i>
<template v-if="organization.location.street">{{ organization.location.street }}, </template>
{{ organization.location.postalCode }} {{ organization.location.city }}
</div>
</b-col>
</b-row>
<div>
<iframe id="oveda-widget" :src="'/' + organization.short_name + '/widget/eventdates'" style="width: 1px; min-width: 100%; max-width:100%;"></iframe>
</div>
</div>
</b-overlay>
</div>
`,
data: () => ({
isLoading: false,
organization: null,
}),
computed: {
organizationId() {
return this.$route.params.organization_id;
},
},
mounted() {
this.isLoading = false;
this.organization = null;
this.loadData();
},
methods: {
loadData() {
axios
.get(`/api/v1/organizations/${this.organizationId}`, {
withCredentials: true,
handleLoading: this.handleLoading,
})
.then((response) => {
this.organization = response.data;
Vue.nextTick(function () {
iFrameResize({ minHeight: 300 }, '#oveda-widget');
});
});
},
handleLoading(isLoading) {
this.isLoading = isLoading;
},
},
};

View File

@ -681,7 +681,10 @@
{% endif %}
<div class="col-12 col-sm">
<div class="font-weight-bold">{{ event.admin_unit.name }}{{ render_admin_unit_badges(event.admin_unit) }}</div>
<div class="font-weight-bold">
<a href="{{ url_for('organizations', path=event.admin_unit.id) }}">{{ event.admin_unit.name }}</a>
{{ render_admin_unit_badges(event.admin_unit) }}
</div>
{{ render_link_prop(event.admin_unit.url) }}
{{ render_email_prop(event.admin_unit.email) }}
@ -1552,6 +1555,7 @@ $('#allday').on('change', function() {
$("#name").rules("add", {
remote: {
param: {
url: "{{ url_for('js_check_org_name') }}",
type: "post"
{% if admin_unit %}
@ -1560,11 +1564,12 @@ $('#allday').on('change', function() {
return "{{ admin_unit.id }}";
}
}
{% if admin_unit.name %}
,depends: function() {
return $('#name').val() != '{{ admin_unit.name }}';
}
{% endif %}
}
{% if admin_unit and admin_unit.name %}
,depends: function(element) {
return $('#name').val() !== '{{ admin_unit.name }}';
}
{% endif %}
}
});
@ -1572,6 +1577,7 @@ $('#allday').on('change', function() {
$("#short_name").rules("add", "shortName");
$("#short_name").rules("add", {
remote: {
param: {
url: "{{ url_for('js_check_org_short_name') }}",
type: "post"
{% if admin_unit %}
@ -1580,11 +1586,12 @@ $('#allday').on('change', function() {
return "{{ admin_unit.id }}";
}
}
{% if admin_unit.short_name %}
,depends: function() {
return $('#short_name').val() != '{{ admin_unit.short_name }}';
}
{% endif %}
}
{% if admin_unit.short_name %}
,depends: function(element) {
return $('#short_name').val() !== '{{ admin_unit.short_name }}';
}
{% endif %}
}
});

View File

@ -1,18 +0,0 @@
{% extends "layout.html" %}
{%- block title -%}
oveda - Terminkalender für Goslar und Hahnenklee
{%- endblock -%}
{% block content %}
{% block header_before_site_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.min.js" integrity="sha512-dnvR4Aebv5bAtJxDunq3eE8puKAJrY9GBJYl9GC6lTOEC76s1dbDfJFcL9GyzpaDW4vlI/UjR8sKbc1j6Ynx6w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
$(function () {
iFrameResize({ minHeight: 300 }, '#oveda-widget');
});
</script>
{%- endblock -%}
<h1>Terminkalender für Goslar und Hahnenklee</h1>
<iframe id="oveda-widget" src="{{ url_for('widget_event_dates', au_short_name='goslar') }}" style="width: 1px; min-width: 100%; max-width:100%;"></iframe>
{% endblock %}

View File

@ -173,8 +173,8 @@
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav mr-auto">
<a class="nav-item nav-link" href="{{ url_for('event_dates') }}">{{ _('Events') }}</a>
<a class="nav-item nav-link" href="{{ url_for('organizations') }}">{{ _('Organizations') }}</a>
<a class="nav-item nav-link" href="{{ url_for('planing') }}">{{ _('Planing') }}</a>
<a class="nav-item nav-link" href="{{ url_for('example') }}">{{ _('Example') }}</a>
</div>
<div class="navbar-nav navbar-right">
{% if current_user.is_authenticated %}
@ -268,6 +268,7 @@
{% endif %}
<a class="dropdown-item" href="{{ url_for('admin_unit_update', id=current_admin_unit.id) }}">{{ _('Settings') }}</a>
<a class="dropdown-item" href="{{ url_for('manage_admin_unit_widgets', id=current_admin_unit.id) }}">{{ _('Widgets') }}</a>
<a class="dropdown-item" href="{{ url_for('organizations', path=current_admin_unit.id) }}">{{ _('Profile') }}</a>
</div>
</li>
</div>

View File

@ -52,6 +52,12 @@
en: {
shared: {
models: {
adminUnit: {
className: "Organization",
listName: "Organizations",
name: "Name",
shortName: "Short name",
},
adminUnitRelation: {
targetOrganization: "Other organization",
autoVerifyEventReferenceRequests: "Verify reference requests automatically",
@ -132,6 +138,12 @@
de: {
shared: {
models: {
adminUnit: {
className: "Organisation",
listName: "Organisationen",
name: "Name",
shortName: "Kurzname",
},
adminUnitRelation: {
targetOrganization: "Andere Organisation",
autoVerifyEventReferenceRequests:

View File

@ -0,0 +1,34 @@
{% extends "layout_vue.html" %}
{%- block title -%}
{{ _('Organizations') }}
{%- endblock -%}
{% block header_before_site_js %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.min.js" integrity="sha512-dnvR4Aebv5bAtJxDunq3eE8puKAJrY9GBJYl9GC6lTOEC76s1dbDfJFcL9GyzpaDW4vlI/UjR8sKbc1j6Ynx6w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
{%- endblock -%}
{% block component_scripts %}
<script src="{{ url_for('static', filename='vue/organization/list.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/organization/read.vue.js')}}"></script>
{% endblock %}
{% block component_definitions %}
Vue.component("OrganizationList", OrganizationList);
Vue.component("OrganizationRead", OrganizationRead);
{% endblock %}
{% block vue_routes %}
const routes = [
{
path: "/organizations",
component: OrganizationList,
},
{
path: "/organizations/:organization_id",
component: OrganizationRead,
name: "OrganizationById",
},
];
{% endblock %}

View File

@ -0,0 +1,9 @@
from flask import render_template
from project import app
@app.route("/organizations")
@app.route("/organizations/<path:path>")
def organizations(path=None):
return render_template("organization/main.html")

View File

@ -32,11 +32,6 @@ def home():
)
@app.route("/example")
def example():
return render_template("example.html")
@app.route("/tos")
def tos():
title = gettext("Terms of service")

View File

@ -41,8 +41,11 @@ def test_list(client, seeder, utils):
def test_search(client, seeder, utils):
from project.dateutils import create_berlin_date
user_id, admin_unit_id = seeder.setup_base()
event_id = seeder.create_event(admin_unit_id)
start = create_berlin_date(2020, 10, 3, 10)
event_id = seeder.create_event(admin_unit_id, start=start)
seeder.create_event(admin_unit_id, draft=True)
seeder.create_event_unverified()
@ -50,7 +53,7 @@ def test_search(client, seeder, utils):
response = utils.get_ok(url)
assert len(response.json["items"]) == 1
assert response.json["items"][0]["event"]["id"] == event_id
assert response.json["items"][0]["start"].endswith("+02:00")
assert response.json["items"][0]["start"] == "2020-10-03T10:00:00+02:00"
url = utils.get_url("api_v1_event_date_search", keyword="name")
response = utils.get_ok(url)

View File

@ -15,12 +15,61 @@ def test_read(client, seeder, utils):
def test_list(client, seeder, utils):
user_id, admin_unit_id = seeder.setup_base()
seeder.setup_base()
url = utils.get_url("api_v1_organization_list", keyword="crew")
utils.get_ok(url)
def test_list_unverified(client, app, seeder, utils):
_, verified_admin_unit_id = seeder.setup_base(
email="verified@test.de",
log_in=False,
name="Verified",
admin_unit_verified=True,
)
_, unverified_admin_unit_id = seeder.setup_base(
email="unverified@test.de",
log_in=False,
name="Unverified",
admin_unit_verified=False,
)
# Unauthorisierte Nutzer sehen nur verifizierte Organisationen
url = utils.get_url("api_v1_organization_list")
response = utils.get_ok(url)
assert len(response.json["items"]) == 1
assert response.json["items"][0]["id"] == verified_admin_unit_id
# user_id1 sieht verified_admin_unit_id, weil sie verifiziert ist,
# aber nicht unverified_admin_unit_id, weil sie nicht verifiziert ist.
utils.login("verified@test.de")
response = utils.get_ok(url)
assert len(response.json["items"]) == 1
assert response.json["items"][0]["id"] == verified_admin_unit_id
# Authorisierte Nutzer, die Organisationen verifizieren dürfen, sehen alle Organisationen.
# "admin@oveda.de" ist Mitglied der Organisation "Oveda", die andere Organisationen verifizieren darf.
with app.app_context():
from project.services.admin_unit import get_admin_unit_by_name
oveda_id = get_admin_unit_by_name("Oveda").id
utils.logout()
utils.login("admin@oveda.de")
response = utils.get_ok(url)
assert len(response.json["items"]) == 3
assert response.json["items"][0]["id"] == oveda_id
assert response.json["items"][1]["id"] == unverified_admin_unit_id
assert response.json["items"][2]["id"] == verified_admin_unit_id
# Globale Admins dürfen alle Organisationen sehen
seeder.create_user("admin@test.de", admin=True)
utils.logout()
utils.login("admin@test.de")
response = utils.get_ok(url)
assert len(response.json["items"]) == 3
def test_event_date_search(client, seeder, utils):
user_id, admin_unit_id = seeder.setup_base(log_in=False)
event_id = seeder.create_event(admin_unit_id)

View File

@ -4,11 +4,20 @@ class Seeder(object):
self._db = db
self._utils = utils
def setup_base(self, admin=False, log_in=True, admin_unit_verified=True):
user_id = self.create_user(admin=admin)
def setup_base(
self,
admin=False,
log_in=True,
admin_unit_verified=True,
email="test@test.de",
name="Meine Crew",
):
user_id = self.create_user(email=email, admin=admin)
if log_in:
self._utils.login()
admin_unit_id = self.create_admin_unit(user_id, verified=admin_unit_verified)
admin_unit_id = self.create_admin_unit(
user_id, name=name, verified=admin_unit_verified
)
return (user_id, admin_unit_id)
def setup_base_event_verifier(self):
@ -86,7 +95,7 @@ class Seeder(object):
if other_admin_unit:
other_admin_unit_id = other_admin_unit.id
else:
other_user_id = self.create_user("other@test.de")
other_user_id = self.create_user("admin@oveda.de")
other_admin_unit_id = self.create_admin_unit(
other_user_id, "Oveda", can_verify_other=True
)

View File

@ -12,8 +12,8 @@ def test_home(client, seeder, utils):
utils.assert_response_redirect(response, "home")
def test_example(client, seeder, utils):
url = utils.get_url("example")
def test_organizations(client, seeder, utils):
url = utils.get_url("organizations")
utils.get_ok(url)