Syncing fork to upstream (#1)

Sync master with master of pretix/pretix@300f8f6

* Automatically sort new products to the end

* Drop "squash your commits" from the dev guide

* Add variation descriptions and allow to order addons

* Link to Django's runserver options in dev docs

* Allow <br> tags in rich text

* Copy from event: deal with deleted items

* Make validate_cart useful together with addons

* Fix collapsing panels in the addon choice step

* Button text change if addons are present

* Update translations

* Squash migrations and bump version

* Ticket PDFs: Do not hide attendee name if code is hidden

* Add a user guide on payments

* Link PayPal and Stripe documentation in the respective forms

* Hide payment fees if they are all equal to 0.00

* Refs #39 -- New concept of "teams" (#478)

* New models

* CRUD UI

* UI for adding/removing team members

* Log display for teams

* Fix invitations, move frontend

* Drop old models (incomplete)

* Drop more old stuff

* Drop even more old stuff

* Fix tests

* Fix permission test

* flake8 fix

* Add tests fore the new code

* Rebase migrations

* Fix typo in method name

* Update translations

* Force ordering of events on dashboard

* Fix typos in events

* Prepare the pretixdroid API for an async mode in the app

* Pretixdroid tests: Ignore microseconds (chopped by mysql)

* pretixdroid API: Add related lookups

* Add idempotenty nonces to pretixdroid API

* pretixdroid: force-accepting unpaids and time display

* Marked webfonts as binary files (#487)

Webfonts now listed as binary in `.gitattributes`.
Works on pretix/pretix#486

* Fix #456 -- Allow products to be excluded from ticket-generation (#483)

* Added non-admission setting to event

`ticket_download_nonadm` now setting in storage. Still need logic for
order page/PDF generation.
Works on pretix/pretix#456.

* Download button considers `ticket_download_nonadm`

Modified Django tags to look at item admission attribute and
`ticket_download_nonadm` setting.
Works on pretix/pretix#456.

* Ticket output for non-admission disabled

PDFs/etc. will only be permitted/generated for items with the
`admission` attribute, or if the `ticket_download_nonadm` event setting
is true. Applies to single and whole-order ticket downloads.
Works on pretix/pretix#456.

* Fixed product exclusion in PDF output

Forgot PDF output was a plugin, now includes same check as base
`BaseTicketOutput.generate_order`.
Works on pretix/pretix#456

* Mail signature (#485)

* added signature field -- no function yet

* added mail signature feature

* fixed style issue

* fixed problem with signature default

* added unit test for mail signatures

* added unit test for mail signatures

* [WIP] Fix #447 -- Sendmail plugin: Create new mail based on an old one (#476)

* send old email content to the new one

* error key event

* test commit

* query bad ID

* query bad ID

* query bad ID

* query bad ID

* Update pretixdroid API version

* Refs #447 -- Extend copying old mails to subject and receipients

* Fixed bugs and added test for date range rendering (#488)

* fixed bug for same dates, added unit check for daterange

* fixed local language override in unit test

* Fix #297 -- pretixdroid: Show metrics in the control panel (#481)

* add checkin status page

add dashboard widget
add checkin page under orders

* modify checkin logic

added new fields in checkin page
added filter items

* add tests for checkins & minor improvement

* support addin_product & noadm setting logic

* remove name ordering check test case

* Fix #379 -- Add logo to event organizers (#431)

* [WIP] Add logo to event organizers.

* Fix indentation issues.

* Refactor code

Refactor code

Refactor code

* Add new migration

* Take files into account for organizer sform (settings form)

* Fix grammer

* Make bootstrap form errors specific to each fieldset

* Display logo on organizer's page

* Fix PR issues

Fix PR issues

Fix PR issues

* Reorder imports

* Remove conflicting migration

* Fix rebase conflict

* Fix #41 -- Drag-and-drop ticket editor

Undo/redo

Useful toolbox

Font selection

Add text content

Use hex for colors

JS-side dump and load

Save

Load layout, proper undo/redo

First steps to Python rendering

More PDF rendering

Copy and paste

Buttons for keyboard actions

Splash Screen

Block unbeforeunload in dirty state

Remove debugging output

Preview

Upload new PDFs via the editor

Fix bugs during PDF reload, link in settings form

New default ticket

Add OpenSans BI

Custom fonts, fix tests

* Added bootstrap-colorpicker

* Allow inline PDF display in CSP header

* Add fontpack to list of plugins

* Update German translation

* Add ticketoutputpdf's assets to MANIFEST.i

* Fix migration of old ticket styles

* Fix iCal download URL

* Multi-line location field, new field for admission time

* Admission date and time in editor

* Remove icon from "add to calendar"

* Try to fix PDF display problems in Safari

* Proxy cachedfiles that are used as editor previews

* Check Event.presale_is_running in more places

* Fix CSS generation with an empty color field

* Fix missing placeholders and reformat the sendmail view

* Fix bug that lead to wrong payment amount when switching payment method to PayPal later

* Update translation
This commit is contained in:
Ian Williams
2017-05-11 09:22:03 -07:00
committed by GitHub
parent 5d6083dce4
commit 847d409a00
181 changed files with 98901 additions and 2930 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.3.0-alpha1"
__version__ = "1.3.0"
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-27 09:11
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.organizer
def create_teams(apps, schema_editor):
Event = apps.get_model('pretixbase', 'Event')
Organizer = apps.get_model('pretixbase', 'Organizer')
Team = apps.get_model('pretixbase', 'Team')
TeamInvite = apps.get_model('pretixbase', 'TeamInvite')
EventPermission = apps.get_model('pretixbase', 'EventPermission')
OrganizerPermission = apps.get_model('pretixbase', 'OrganizerPermission')
for o in Organizer.objects.prefetch_related('events'):
for e in o.events.all():
teams = {}
for p in e.user_perms.all():
pkey = (p.can_change_settings, p.can_change_items, p.can_view_orders,
p.can_change_permissions, p.can_change_orders, p.can_view_vouchers,
p.can_change_vouchers)
if pkey not in teams:
team = Team()
team.can_change_event_settings = p.can_change_settings
team.can_change_items = p.can_change_items
team.can_view_orders = p.can_view_orders
team.can_change_orders = p.can_change_orders
team.can_view_vouchers = p.can_view_vouchers
team.can_change_vouchers = p.can_change_vouchers
team.organizer = o
team.name = '{} Team {}'.format(
str(e.name), len(teams) + 1
)
team.save()
team.limit_events.add(e)
teams[pkey] = team
if p.user:
teams[pkey].members.add(p.user)
else:
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
teams = {}
for p in o.user_perms.all():
pkey = (p.can_create_events, p.can_change_permissions)
if pkey not in teams:
team = Team()
team.can_change_organizer_settings = True
team.can_create_events = p.can_create_events
team.can_change_teams = p.can_change_permissions
team.organizer = o
team.name = '{} Team {}'.format(
str(o.name), len(teams) + 1
)
team.save()
teams[pkey] = team
if p.user:
teams[pkey].members.add(p.user)
else:
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0051_auto_20170206_2027_squashed_0057_auto_20170501_2116'),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=190, verbose_name='Team name')),
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
('can_create_events', models.BooleanField(default=False, verbose_name='Can create events')),
('can_change_teams', models.BooleanField(default=False, verbose_name='Can change permissions')),
('can_change_organizer_settings', models.BooleanField(default=False, verbose_name='Can change organizer settings')),
('can_change_event_settings', models.BooleanField(default=False, verbose_name='Can change event settings')),
('can_change_items', models.BooleanField(default=False, verbose_name='Can change product settings')),
('can_view_orders', models.BooleanField(default=False, verbose_name='Can view orders')),
('can_change_orders', models.BooleanField(default=False, verbose_name='Can change orders')),
('can_view_vouchers', models.BooleanField(default=False, verbose_name='Can view vouchers')),
('can_change_vouchers', models.BooleanField(default=False, verbose_name='Can change vouchers')),
('limit_events', models.ManyToManyField(to='pretixbase.Event', verbose_name='Limit to events')),
('members', models.ManyToManyField(related_name='teams', to=settings.AUTH_USER_MODEL, verbose_name='Team members')),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='pretixbase.Organizer')),
],
options={
'verbose_name_plural': 'Teams',
'verbose_name': 'Team',
},
),
migrations.CreateModel(
name='TeamInvite',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(blank=True, max_length=254, null=True)),
('token', models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='pretixbase.Team')),
],
),
migrations.RunPython(
create_teams, migrations.RunPython.noop
)
]
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-05-01 21:16
from __future__ import unicode_literals
import i18nfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0056_auto_20170414_1044'),
]
operations = [
migrations.AlterModelOptions(
name='itemaddon',
options={'ordering': ('position', 'pk')},
),
migrations.AddField(
model_name='itemaddon',
name='position',
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
),
migrations.AddField(
model_name='itemvariation',
name='description',
field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'),
),
]
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-29 10:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0052_team_teaminvite'),
]
operations = [
migrations.RemoveField(
model_name='eventpermission',
name='event',
),
migrations.RemoveField(
model_name='eventpermission',
name='user',
),
migrations.RemoveField(
model_name='organizerpermission',
name='organizer',
),
migrations.RemoveField(
model_name='organizerpermission',
name='user',
),
migrations.RemoveField(
model_name='event',
name='permitted',
),
migrations.RemoveField(
model_name='organizer',
name='permitted',
),
migrations.AlterField(
model_name='team',
name='can_change_teams',
field=models.BooleanField(default=False, verbose_name='Can change teams and permissions'),
),
migrations.AlterField(
model_name='team',
name='limit_events',
field=models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events'),
),
migrations.DeleteModel(
name='EventPermission',
),
migrations.DeleteModel(
name='OrganizerPermission',
),
]
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-05-04 07:06
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0058_auto_20170429_1020'),
]
operations = [
migrations.AddField(
model_name='checkin',
name='nonce',
field=models.CharField(blank=True, max_length=190, null=True),
),
]
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-05-10 10:27
from __future__ import unicode_literals
import i18nfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0059_checkin_nonce'),
]
operations = [
migrations.AddField(
model_name='event',
name='date_admission',
field=models.DateTimeField(blank=True, null=True, verbose_name='Admission time'),
),
migrations.AlterField(
model_name='event',
name='location',
field=i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location'),
),
]
+2 -2
View File
@@ -3,7 +3,7 @@ from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin
from .event import (
Event, Event_SettingsStore, EventLock, EventPermission, RequiredAction,
Event, Event_SettingsStore, EventLock, RequiredAction,
generate_invite_token,
)
from .invoices import Invoice, InvoiceLine, invoice_filename
@@ -18,6 +18,6 @@ from .orders import (
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_secret,
)
from .organizer import Organizer, Organizer_SettingsStore, OrganizerPermission
from .organizer import Organizer, Organizer_SettingsStore, Team, TeamInvite
from .vouchers import Voucher
from .waitinglist import WaitingListEntry
+104
View File
@@ -1,9 +1,12 @@
from typing import Union
from django.conf import settings
from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin,
)
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device
@@ -81,6 +84,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
objects = UserManager()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._teamcache = {}
class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
@@ -147,6 +154,103 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return LogEntry.objects.filter(content_type=ContentType.objects.get_for_model(User),
object_id=self.pk)
def _get_teams_for_organizer(self, organizer):
if 'o{}'.format(organizer.pk) not in self._teamcache:
self._teamcache['o{}'.format(organizer.pk)] = list(self.teams.filter(organizer=organizer))
return self._teamcache['o{}'.format(organizer.pk)]
def _get_teams_for_event(self, organizer, event):
if 'e{}'.format(event.pk) not in self._teamcache:
self._teamcache['e{}'.format(event.pk)] = list(self.teams.filter(organizer=organizer).filter(
Q(all_events=True) | Q(limit_events=event)
))
return self._teamcache['e{}'.format(event.pk)]
class SuperuserPermissionSet:
def __contains__(self, item):
return True
def get_event_permission_set(self, organizer, event) -> Union[set, SuperuserPermissionSet]:
"""
Gets a set of permissions (as strings) that a user holds for a particular event
:param organizer: The organizer of the event
:param event: The event to check
:return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where
a in b always returns true).
"""
if self.is_superuser:
return self.SuperuserPermissionSet()
teams = self._get_teams_for_event(organizer, event)
return set.union(*[t.permission_set() for t in teams])
def get_organizer_permission_set(self, organizer) -> Union[set, SuperuserPermissionSet]:
"""
Gets a set of permissions (as strings) that a user holds for a particular organizer
:param organizer: The organizer of the event
:return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where
a in b always returns true).
"""
if self.is_superuser:
return self.SuperuserPermissionSet()
teams = self._get_teams_for_organizer(organizer)
return set.union(*[t.permission_set() for t in teams])
def has_event_permission(self, organizer, event, perm_name=None) -> bool:
"""
Checks if this user is part of any team that grants access of type ``perm_name``
to the event ``event``.
:param organizer: The organizer of the event
:param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams``
:return: bool
"""
if self.is_superuser:
return True
teams = self._get_teams_for_event(organizer, event)
if teams:
self._teamcache['e{}'.format(event.pk)] = teams
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
def has_organizer_permission(self, organizer, perm_name=None):
"""
Checks if this user is part of any team that grants access of type ``perm_name``
to the organizer ``organizer``.
:param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams``
:return: bool
"""
if self.is_superuser:
return True
teams = self._get_teams_for_organizer(organizer)
if teams:
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
def get_events_with_any_permission(self):
"""
Returns a queryset of events the user has any permissions to.
:return: Iterable of Events
"""
from .event import Event
if self.is_superuser:
return Event.objects.all()
return Event.objects.filter(
Q(organizer_id__in=self.teams.filter(all_events=True).values_list('organizer', flat=True))
| Q(id__in=self.teams.values_list('limit_events__id', flat=True))
)
class U2FDevice(Device):
json_data = models.TextField()
+1
View File
@@ -8,3 +8,4 @@ class Checkin(models.Model):
"""
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
datetime = models.DateTimeField(default=now)
nonce = models.CharField(max_length=190, null=True, blank=True)
+18 -71
View File
@@ -13,7 +13,7 @@ from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.email import CustomSMTPBackend
from pretix.base.models.base import LoggedModel
@@ -21,7 +21,6 @@ from pretix.base.validators import EventSlugBlacklistValidator
from pretix.helpers.daterange import daterange
from ..settings import settings_hierarkey
from .auth import User
from .organizer import Organizer
@@ -79,8 +78,6 @@ class Event(LoggedModel):
verbose_name=_("Short form"),
)
live = models.BooleanField(default=False, verbose_name=_("Shop is live"))
permitted = models.ManyToManyField(User, through='EventPermission',
related_name="events", )
currency = models.CharField(max_length=10,
verbose_name=_("Default currency"),
choices=CURRENCY_CHOICES,
@@ -88,6 +85,8 @@ class Event(LoggedModel):
date_from = models.DateTimeField(verbose_name=_("Event start time"))
date_to = models.DateTimeField(null=True, blank=True,
verbose_name=_("Event end time"))
date_admission = models.DateTimeField(null=True, blank=True,
verbose_name=_("Admission time"))
is_public = models.BooleanField(default=False,
verbose_name=_("Visible in public lists"),
help_text=_("If selected, this event may show up on the ticket system's start page "
@@ -102,7 +101,7 @@ class Event(LoggedModel):
verbose_name=_("Start of presale"),
help_text=_("No products will be sold before this date."),
)
location = I18nCharField(
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
@@ -140,7 +139,7 @@ class Event(LoggedModel):
return []
return self.plugins.split(",")
def get_date_from_display(self, tz=None) -> str:
def get_date_from_display(self, tz=None, show_times=True) -> str:
"""
Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting.
@@ -148,7 +147,17 @@ class Event(LoggedModel):
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
)
def get_time_from_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start time of the event, ignoring
the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz), "TIME_FORMAT"
)
def get_date_to_display(self, tz=None) -> str:
@@ -269,7 +278,8 @@ class Event(LoggedModel):
q.event = self
q.save()
for i in items:
q.items.add(item_map[i.pk])
if i.pk in item_map:
q.items.add(item_map[i.pk])
for v in vars:
q.variations.add(variation_map[v.pk])
@@ -306,69 +316,6 @@ def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class EventPermission(models.Model):
"""
The relation between an Event and a User who has permissions to
access an event.
:param event: The event this permission refers to
:type event: Event
:param user: The user this permission set applies to
:type user: User
:param can_change_settings: If ``True``, the user can change all basic settings for this event.
:type can_change_settings: bool
:param can_change_items: If ``True``, the user can change and add items and related objects for this event.
:type can_change_items: bool
:param can_view_orders: If ``True``, the user can inspect details of all orders.
:type can_view_orders: bool
:param can_change_orders: If ``True``, the user can change details of orders
:type can_change_orders: bool
"""
event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_change_settings = models.BooleanField(
default=True,
verbose_name=_("Can change event settings")
)
can_change_items = models.BooleanField(
default=True,
verbose_name=_("Can change product settings")
)
can_view_orders = models.BooleanField(
default=True,
verbose_name=_("Can view orders")
)
can_change_permissions = models.BooleanField(
default=True,
verbose_name=_("Can change permissions")
)
can_change_orders = models.BooleanField(
default=True,
verbose_name=_("Can change orders")
)
can_view_vouchers = models.BooleanField(
default=True,
verbose_name=_("Can view vouchers")
)
can_change_vouchers = models.BooleanField(
default=True,
verbose_name=_("Can change vouchers")
)
class Meta:
verbose_name = _("Event permission")
verbose_name_plural = _("Event permissions")
def __str__(self):
return _("%(name)s on %(object)s") % {
'name': str(self.user),
'object': str(self.event),
}
class EventLock(models.Model):
event = models.CharField(max_length=36, primary_key=True)
date = models.DateTimeField(auto_now=True)
+12
View File
@@ -310,6 +310,8 @@ class ItemVariation(models.Model):
:type item: Item
:param value: A string defining this variation
:type value: str
:param description: A short description
:type description: str
:param active: Whether this variation is being sold.
:type active: bool
:param default_price: This variation's default price
@@ -327,6 +329,11 @@ class ItemVariation(models.Model):
default=True,
verbose_name=_("Active"),
)
description = I18nTextField(
verbose_name=_("Description"),
help_text=_("This is shown below the variation name in lists."),
null=True, blank=True,
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
@@ -413,9 +420,14 @@ class ItemAddOn(models.Model):
default=1,
verbose_name=_('Maximum number')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
class Meta:
unique_together = (('base_item', 'addon_category'),)
ordering = ('position', 'pk')
def clean(self):
if self.max_count < self.min_count:
+112 -22
View File
@@ -42,8 +42,6 @@ class Organizer(LoggedModel):
],
verbose_name=_("Short form"),
)
permitted = models.ManyToManyField(User, through='OrganizerPermission',
related_name="organizers")
class Meta:
verbose_name = _("Organizer")
@@ -74,39 +72,131 @@ def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class OrganizerPermission(models.Model):
class Team(LoggedModel):
"""
The relation between an Organizer and a User who has permissions to
access an organizer profile.
A team is a collection of people given certain access rights to one or more events of an organizer.
:param organizer: The organizer this relation refers to
:param name: The name of this team
:type name: str
:param organizer: The organizer this team belongs to
:type organizer: Organizer
:param user: The user this set of permissions is valid for
:type user: User
:param can_create_events: Whether or not this user can create new events with this
organizer account.
:param members: A set of users who belong to this team
:param all_events: Whether this team has access to all events of this organizer
:type all_events: bool
:param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``.
:param can_create_events: Whether or not the members can create new events with this organizer account.
:type can_create_events: bool
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
:type can_change_teams: bool
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
:type can_change_organizer_settings: bool
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
:type can_change_event_settings: bool
:param can_change_items: If ``True``, the members can change and add items and related objects for the associated events.
:type can_change_items: bool
:param can_view_orders: If ``True``, the members can inspect details of all orders of the associated events.
:type can_view_orders: bool
:param can_change_orders: If ``True``, the members can change details of orders of the associated events.
:type can_change_orders: bool
:param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events.
:type can_view_vouchers: bool
:param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events.
:type can_change_vouchers: bool
"""
organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE)
name = models.CharField(max_length=190, verbose_name=_("Team name"))
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_create_events = models.BooleanField(
default=True,
default=False,
verbose_name=_("Can create events"),
)
can_change_permissions = models.BooleanField(
default=True,
verbose_name=_("Can change permissions"),
can_change_teams = models.BooleanField(
default=False,
verbose_name=_("Can change teams and permissions"),
)
can_change_organizer_settings = models.BooleanField(
default=False,
verbose_name=_("Can change organizer settings")
)
class Meta:
verbose_name = _("Organizer permission")
verbose_name_plural = _("Organizer permissions")
can_change_event_settings = models.BooleanField(
default=False,
verbose_name=_("Can change event settings")
)
can_change_items = models.BooleanField(
default=False,
verbose_name=_("Can change product settings")
)
can_view_orders = models.BooleanField(
default=False,
verbose_name=_("Can view orders")
)
can_change_orders = models.BooleanField(
default=False,
verbose_name=_("Can change orders")
)
can_view_vouchers = models.BooleanField(
default=False,
verbose_name=_("Can view vouchers")
)
can_change_vouchers = models.BooleanField(
default=False,
verbose_name=_("Can change vouchers")
)
def __str__(self) -> str:
return _("%(name)s on %(object)s") % {
'name': str(self.user),
'name': str(self.name),
'object': str(self.organizer),
}
def permission_set(self) -> set:
attribs = dir(self)
return {
a for a in attribs if a.startswith('can_') and self.has_permission(a)
}
@property
def can_change_settings(self): # Legacy compatiblilty
return self.can_change_event_settings
def has_permission(self, perm_name):
try:
return getattr(self, perm_name)
except AttributeError:
raise ValueError('Invalid required permission: %s' % perm_name)
def permission_for_event(self, event):
if self.all_events:
return event.organizer_id == self.organizer_id
else:
return self.limit_events.filter(pk=event.pk).exists()
class Meta:
verbose_name = _("Team")
verbose_name_plural = _("Teams")
class TeamInvite(models.Model):
"""
A TeamInvite represents someone who has been invited to a team but hasn't accept the invitation
yet.
:param team: The team the person is invited to
:type team: Team
:param email: The email the invite has been sent to
:type email: str
:param token: The secret required to redeem the invite
:type token: str
"""
team = models.ForeignKey(Team, related_name="invites", on_delete=models.CASCADE)
email = models.EmailField(null=True, blank=True)
token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
def __str__(self) -> str:
return _("Invite to team '{team}' for '{email}'").format(
team=str(self.team), email=self.email
)
+12 -8
View File
@@ -1,7 +1,7 @@
from collections import OrderedDict
from datetime import date
from decimal import Decimal
from typing import Any, Dict
from typing import Any, Dict, Union
import pytz
from django import forms
@@ -150,9 +150,9 @@ class BasePaymentProvider:
forms.BooleanField(
label=_('Calculate the fee from the total value including the fee.'),
help_text=_('We recommend you to enable this if you want your users to pay the payment fees of your '
'payment provider. <a href="/control/help/payment/fee_reverse" target="_blank">Click here '
'payment provider. <a href="{docs_url}" target="_blank">Click here '
'for detailled information on what this does.</a> Don\'t forget to set the correct fees '
'above!'),
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False
)),
('_invoice_text',
@@ -266,7 +266,7 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> "bool|str":
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
"""
Will be called after the user selects this provider as his payment method.
If you provided a form to the user to enter payment data, this method should
@@ -394,14 +394,14 @@ class BasePaymentProvider:
"""
return False
def retry_prepare(self, request: HttpRequest, order: Order) -> "bool|str":
def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Deprecated, use order_prepare instead
"""
raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead')
return self.order_prepare(request, order)
def order_prepare(self, request: HttpRequest, order: Order) -> "bool|str":
def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the user retries to pay an unpaid order (after the user filled in
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
@@ -409,6 +409,10 @@ class BasePaymentProvider:
It should return and report errors the same way as :py:meth:`checkout_prepare`, but
receives an ``Order`` object instead of a cart object.
Note: The ``Order`` object given to this method might be different from the version
stored in the database as it's total will already contain the payment fee for the
new payment method.
"""
form = self.payment_form(request)
if form.is_valid():
@@ -458,7 +462,7 @@ class BasePaymentProvider:
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
'please transfer the money back manually.')
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str":
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the event administrator confirms the refund.
@@ -523,7 +527,7 @@ class FreeOrderProvider(BasePaymentProvider):
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str":
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the event administrator confirms the refund.
+10
View File
@@ -114,6 +114,16 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
subject = "[%s] %s" % (prefix, subject)
body_plain += "\r\n\r\n-- \r\n"
signature = str(event.settings.get('mail_text_signature'))
if signature:
signature = signature.format(event=event.name)
signature_md = signature.replace('\n', '<br>\n')
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
htmlctx['signature'] = signature_md
body_plain += signature
body_plain += "\r\n\r\n-- \r\n"
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
+8
View File
@@ -163,6 +163,10 @@ DEFAULTS = {
'default': 'False',
'type': bool
},
'ticket_download_nonadm': {
'default': 'True',
'type': bool
},
'last_order_modification_date': {
'default': None,
'type': datetime
@@ -187,6 +191,10 @@ DEFAULTS = {
'default': settings.MAIL_FROM,
'type': str
},
'mail_text_signature': {
'type': LazyI18nString,
'default': ""
},
'mail_text_resend_link': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
@@ -148,6 +148,18 @@
</td>
</tr>
{% endif %}
{% if signature %}
<tr>
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{{ signature | safe }}
</div>
</td>
</tr>
{% endif %}
<tr>
<td class="footer">
<div>
@@ -11,6 +11,7 @@ ALLOWED_TAGS = [
'acronym',
'b',
'blockquote',
'br',
'code',
'em',
'i',
+3
View File
@@ -54,12 +54,15 @@ class BaseTicketOutput:
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
"""
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for pos in order.positions.all():
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
continue
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
continue
fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1]
+32 -7
View File
@@ -2,6 +2,7 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db.models import Q
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
@@ -26,7 +27,7 @@ class EventWizardFoundationForm(forms.Form):
self.fields['organizer'] = forms.ModelChoiceField(
label=_("Organizer"),
queryset=Organizer.objects.filter(
id__in=self.user.organizer_perms.filter(can_create_events=True).values_list('organizer', flat=True)
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
),
widget=forms.RadioSelect,
empty_label=None,
@@ -111,6 +112,16 @@ class EventWizardBasicsForm(I18nModelForm):
class EventWizardCopyForm(forms.Form):
@staticmethod
def copy_from_queryset(user):
return Event.objects.filter(
Q(organizer_id__in=user.teams.filter(
all_events=True, can_change_event_settings=True, can_change_items=True
).values_list('organizer', flat=True)) | Q(id__in=user.teams.filter(
can_change_event_settings=True, can_change_items=True
).values_list('limit_events__id', flat=True))
)
def __init__(self, *args, **kwargs):
kwargs.pop('organizer')
kwargs.pop('locales')
@@ -118,11 +129,7 @@ class EventWizardCopyForm(forms.Form):
super().__init__(*args, **kwargs)
self.fields['copy_from_event'] = forms.ModelChoiceField(
label=_("Copy configuration from"),
queryset=Event.objects.filter(
id__in=self.user.event_perms.filter(
can_change_items=True, can_change_settings=True
).values_list('event', flat=True)
),
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
widget=forms.RadioSelect,
empty_label=_('Do not copy'),
required=False
@@ -136,6 +143,7 @@ class EventUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
self.fields['location'].widget.attrs['rows'] = '3'
class Meta:
model = Event
@@ -146,6 +154,7 @@ class EventUpdateForm(I18nModelForm):
'currency',
'date_from',
'date_to',
'date_admission',
'is_public',
'presale_start',
'presale_end',
@@ -154,6 +163,7 @@ class EventUpdateForm(I18nModelForm):
widgets = {
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}),
'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
'data-date-after': '#id_presale_start'}),
@@ -467,6 +477,15 @@ class MailSettingsForm(SettingsForm):
label=_("Sender address"),
help_text=_("Sender address for outgoing emails")
)
mail_text_signature = I18nFormField(
label=_("Signature"),
required=False,
widget=I18nTextarea,
help_text=_("This will be attached to every email. Available placeholders: {event}"),
validators=[PlaceholderValidator(['{event}'])]
)
mail_text_order_placed = I18nFormField(
label=_("Text"),
required=False,
@@ -586,7 +605,8 @@ class DisplaySettingsForm(SettingsForm):
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
]
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
logo_image = ExtFileField(
label=_('Logo image'),
@@ -624,6 +644,11 @@ class TicketSettingsForm(SettingsForm):
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
)
ticket_download_nonadm = forms.BooleanField(
label=_("Generate tickets for non-admission products"),
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
)
def prepare_fields(self):
# See clean()
+1
View File
@@ -211,6 +211,7 @@ class ItemVariationForm(I18nModelForm):
'value',
'active',
'default_price',
'description',
]
+47 -2
View File
@@ -1,8 +1,10 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import I18nModelForm
from pretix.base.models import Organizer
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.models import Organizer, Team
from pretix.control.forms import ExtFileField
from pretix.multidomain.models import KnownDomain
@@ -65,3 +67,46 @@ class OrganizerUpdateForm(OrganizerForm):
instance.get_cache().clear()
return instance
class TeamForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all()
class Meta:
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders',
'can_view_vouchers', 'can_change_vouchers']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events'
}),
}
def clean(self):
data = super().clean()
if self.instance.pk and not data['can_change_teams']:
if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter(
can_change_teams=True, members__isnull=False
).exists():
raise ValidationError(_('The changes could not be saved because there would be no remaining team with '
'the permission to change teams and permissions.'))
return data
class OrganizerSettingsForm(SettingsForm):
organizer_logo_image = ExtFileField(
label=_('Logo image'),
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
required=False,
help_text=_('If you provide a logo image, we will by default not show your organization name '
'in the page header. We will show your logo with a maximal height of 120 pixels.')
)
+21 -1
View File
@@ -121,7 +121,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.')
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'),
'pretix.team.created': _('The team has been created.'),
'pretix.team.changed': _('The team settings have been modified.'),
'pretix.team.deleted': _('The team has been deleted.'),
}
data = json.loads(logentry.data)
@@ -149,6 +152,23 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type.startswith('pretix.event.tickets.provider.'):
return _('The settings of a ticket output provider have been changed.')
if logentry.action_type == 'pretix.team.member.added':
return _('{user} has been added to the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.member.removed':
return _('{user} has been removed from the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.member.joined':
return _('{user} has joined the team using the invite sent to {email}.').format(
user=data.get('email'), email=data.get('invite_email')
)
if logentry.action_type == 'pretix.team.invite.created':
return _('{user} has been invited to the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.invite.deleted':
return _('The invite for {user} has been revoked.').format(user=data.get('email'))
if logentry.action_type == 'pretix.user.settings.changed':
text = str(_('Your account settings have been changed.'))
if 'email' in data:
+15 -47
View File
@@ -9,9 +9,7 @@ from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import force_str
from django.utils.translation import ugettext as _
from pretix.base.models import (
Event, EventPermission, Organizer, OrganizerPermission,
)
from pretix.base.models import Event, Organizer
class PermissionMiddleware(MiddlewareMixin):
@@ -61,53 +59,23 @@ class PermissionMiddleware(MiddlewareMixin):
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
events = Event.objects.all() if request.user.is_superuser else request.user.events
request.user.events_cache = events.order_by(
"organizer", "date_from").prefetch_related("organizer")
events = request.user.get_events_with_any_permission()
request.user.events_cache = events.order_by("organizer", "date_from").prefetch_related("organizer")
if 'event' in url.kwargs and 'organizer' in url.kwargs:
try:
if request.user.is_superuser:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0]
request.eventperm = EventPermission(
event=request.event,
user=request.user
)
else:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
permitted__id__exact=request.user.id,
organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0]
request.eventperm = EventPermission.objects.get(
event=request.event,
user=request.user
)
request.organizer = request.event.organizer
except IndexError:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first()
if not request.event or not request.user.has_event_permission(request.event.organizer, request.event):
raise Http404(_("The selected event was not found or you "
"have no permission to administrate it."))
request.organizer = request.event.organizer
request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event)
elif 'organizer' in url.kwargs:
try:
if request.user.is_superuser:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
)[0]
request.orgaperm = OrganizerPermission(
organizer=request.organizer,
user=request.user
)
else:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
permitted__id__exact=request.user.id,
)[0]
request.orgaperm = OrganizerPermission.objects.get(
organizer=request.organizer,
user=request.user
)
except IndexError:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
).first()
if not request.organizer or not request.user.has_organizer_permission(request.organizer):
raise Http404(_("The selected organizer was not found or you "
"have no permission to administrate it."))
request.orgapermset = request.user.get_organizer_permission_set(request.organizer)
+22 -36
View File
@@ -1,37 +1,29 @@
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext as _
from pretix.base.models import EventPermission, OrganizerPermission
def event_permission_required(permission):
"""
This view decorator rejects all requests with a 403 response which are not from
users having the given permission for the event the request is associated with.
"""
if permission == 'can_change_settings':
# Legacy support
permission = 'can_change_event_settings'
def decorator(function):
def wrapper(request, *args, **kw):
if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen
raise PermissionDenied()
if request.user.is_superuser:
allowed = (
request.user.is_superuser
or request.user.has_event_permission(request.organizer, request.event, permission)
)
if allowed:
return function(request, *args, **kw)
try:
perm = EventPermission.objects.get(
event=request.event,
user=request.user
)
except EventPermission.DoesNotExist:
pass
else:
allowed = not permission
try:
if permission:
allowed = getattr(perm, permission)
except AttributeError:
pass
if allowed or request.user.is_superuser:
return function(request, *args, **kw)
raise PermissionDenied(_('You do not have permission to view this content.'))
return wrapper
return decorator
@@ -55,29 +47,23 @@ def organizer_permission_required(permission):
This view decorator rejects all requests with a 403 response which are not from
users having the given permission for the event the request is associated with.
"""
if permission == 'can_change_settings':
# Legacy support
permission = 'can_change_organizer_settings'
def decorator(function):
def wrapper(request, *args, **kw):
if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen
raise PermissionDenied()
if request.user.is_superuser:
allowed = (
request.user.is_superuser
or request.user.has_organizer_permission(request.organizer, permission)
)
if allowed:
return function(request, *args, **kw)
try:
perm = OrganizerPermission.objects.get(
organizer=request.organizer,
user=request.user
)
except OrganizerPermission.DoesNotExist:
pass
else:
allowed = not permission
try:
if permission:
allowed = getattr(perm, permission)
except AttributeError:
pass
if allowed or request.user.is_superuser:
return function(request, *args, **kw)
raise PermissionDenied(_('You do not have permission to view this content.'))
return wrapper
return decorator
@@ -35,6 +35,9 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -65,7 +68,7 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
{{ request.event }} <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li><a href="{% url "control:events" %}">{% trans "Event overview" %}</a></li>
<li><a href="{% url "control:events" %}">{% trans "Events overview" %}</a></li>
{% regroup request.user.events_cache by organizer as event_list %}
{% for g in event_list %}
<li class="dropdown-header">{{ g.grouper }}</li>
@@ -0,0 +1,95 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% block title %}{% trans "Check-ins" %}{% endblock %}
{% block content %}
<h1>{% trans "Check-ins" %}</h1>
<p>
<form class="form-inline helper-display-inline" action="" method="get">
<select name="status" class="form-control">
<option value="">{% trans "All status" %}</option>
<option value="1" {% if request.GET.status == "1" %}selected="selected"{% endif %}>{% trans "Checked in" %}</option>
<option value="0" {% if request.GET.status == "0" %}selected="selected"{% endif %}>{% trans "Not checked in" %}</option>
</select>
<select name="item" class="form-control">
<option value="">{% trans "All products" %}</option>
{% for item in items %}
<option value="{{ item.id }}"
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
{{ item.name }}
</option>
{% endfor %}
</select>
<input type="text" name="user" class="form-control" placeholder="{% trans "Search user" %}" value="{{ request.GET.user }}">
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</form>
</p>
{% if entries|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No check-in record was found.
{% endblocktrans %}
</p>
</div>
{% else %}
{% include "pretixcontrol/pagination.html" %}
<form method="post" action="">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'item'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Email" %} <a href="?{% url_replace request 'ordering' '-email'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Name" %} <a href="?{% url_replace request 'ordering' '-name'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Status" %} <a href="?{% url_replace request 'ordering' '-status'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'status'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Timestamp" %} <a href="?{% url_replace request 'ordering' '-timestamp'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'timestamp'%}"><i class="fa fa-caret-up"></i></a></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
{% with e.checkins.first as checkin %}
<tr>
<td>
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}"
>{{ e.order.code }}</a></strong>
</td>
<td>{{ e.item.name }}</td>
<td>{{ e.order.email }}</td>
<td>
{% if e.addon_to %}
{{ e.addon_to.attendee_name }}
{% elif e.attendee_name %}
{{ e.attendee_name }}
{% endif %}
</td>
<td>
{% if not checkin %}
<span class="label label-danger">{% trans "Not checked in" %}</span>
{% else %}
<span class="label label-success">{% trans "Checked in" %}</span>
{% endif %}
</td>
<td>
{% if checkin %}
{{ checkin.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% endif %}
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</form>
{% endif %}
{% endblock %}
@@ -1,9 +1,10 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to the team of an event that uses pretix for their
you have been invited to a team on pretix, a platform to perform event
ticket sales.
Event: {{ event }}
Organizer: {{ organizer }}
Team: {{ team }}
If you want to join that team, just click on the following link:
{{ url }}
@@ -1,16 +0,0 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to the team of an event organizer that uses pretix
for their ticket sales.
Organizer: {{ organizer }}
If you want to join that team, just click on the following link:
{{ url }}
If you do not want to join, you can safely ignore or delete this email.
Best regards,
Your pretix team
{% endblocktrans %}
@@ -10,7 +10,7 @@
{% trans "Dashboard" %}
</a>
</li>
{% if request.eventperm.can_change_settings or request.eventperm.can_change_permissions %}
{% if 'can_change_event_settings' in request.eventpermset or 'can_change_permissions' in request.eventpermset %}
<li>
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-wrench fa-fw"></i>
@@ -18,7 +18,7 @@
</a>
</li>
{% endif %}
{% if request.eventperm.can_change_items %}
{% if 'can_change_items' in request.eventpermset %}
<li>
<a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}"
class="has-children">
@@ -55,7 +55,7 @@
</ul>
</li>
{% endif %}
{% if request.eventperm.can_view_orders %}
{% if 'can_view_orders' in request.eventpermset %}
<li>
<a href="{% url 'control:event.orders' organizer=request.event.organizer.slug event=request.event.slug %}"
class="has-children">
@@ -90,10 +90,16 @@
{% trans "Waiting list" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.checkins' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.checkins" %}class="active"{% endif %}>
{% trans "Check-ins" %}
</a>
</li>
</ul>
</li>
{% endif %}
{% if request.eventperm.can_view_vouchers %}
{% if 'can_view_vouchers' in request.eventpermset %}
<li>
<a href="{% url 'control:event.vouchers' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.vouchers" %}class="active"{% endif %}>
@@ -15,9 +15,11 @@
{% trans "Customer actions" %}
</option>
{% for up in userlist %}
<option value="{{ up.user_id }}" {% if request.GET.user == up.user_id %}selected="selected"{% endif %}>
{{ up.user }}
</option>
{% if up.user__id %}
<option value="{{ up.user__id }}" {% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
{{ up.user__email }}
</option>
{% endif %}
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
@@ -10,6 +10,7 @@
<legend>{% trans "E-mail settings" %}</legend>
{% bootstrap_field form.mail_prefix layout="horizontal" %}
{% bootstrap_field form.mail_from layout="horizontal" %}
{% bootstrap_field form.mail_text_signature layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "E-mail content" %}</legend>
@@ -1,88 +1,19 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load staticfiles %}
{% load bootstrap3 %}
{% block inside %}
<form action="" method="post" class="form-horizontal form-permissions">
{% csrf_token %}
<fieldset>
<legend>{% trans "Permissions" %}</legend>
{% bootstrap_formset_errors formset %}
{{ formset.management_form }}
<div class="table-responsive">
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "User" %}</th>
<th>{% trans "Change settings" %}</th>
<th>{% trans "Change products" %}</th>
<th>{% trans "View orders" %}</th>
<th>{% trans "Change orders" %}</th>
<th>{% trans "Change permissions" %}</th>
<th>{% trans "View vouchers" %}</th>
<th>{% trans "Change vouchers" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>
{{ form.id }}
{% if form.instance.user %}
{{ form.instance.user }}
{% else %}
{{ form.instance.invite_email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
{% endif %}
</td>
<td>{{ form.can_change_settings }}</td>
<td>{{ form.can_change_items }}</td>
<td>{{ form.can_view_orders }}</td>
<td>{{ form.can_change_orders }}</td>
<td>{{ form.can_change_permissions }}</td>
<td>{{ form.can_view_vouchers }}</td>
<td>{{ form.can_change_vouchers }}</td>
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="9">
<strong>{% trans "Adding a new user" %}</strong><br>
{% blocktrans trimmed %}
To add a new user, you can enter their email address here. If they already have a
pretix account, they will immediately be added to the event. Otherwise, they will
be sent an email with an invitation.
{% endblocktrans %}
</td>
</tr>
<tr>
<td>
<div class="row-fluid">
<div class="col-sm-12">
{% bootstrap_field add_form.user layout='inline' %}
</div>
</div>
</td>
<td>{{ add_form.can_change_settings }}</td>
<td>{{ add_form.can_change_items }}</td>
<td>{{ add_form.can_view_orders }}</td>
<td>{{ add_form.can_change_orders }}</td>
<td>{{ add_form.can_change_permissions }}</td>
<td>{{ add_form.can_view_vouchers }}</td>
<td>{{ add_form.can_change_vouchers }}</td>
</tr>
</tfoot>
</table>
</div>
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
<div class="section-moved">
<img src="{% static "pretixcontrol/img/moved.svg" %}" class="img-moved">
<p>
{% blocktrans trimmed %}
Permission settings have moved and are now configured as part of an organizer account instead
of every event on its own.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.teams" organizer=request.event.organizer.slug %}"
class="btn btn-link btn-lg">{% trans "Go to the organizer team settings" %}</a>
</div>
</form>
{% endblock %}
@@ -12,6 +12,7 @@
{% bootstrap_field form.date_from layout="horizontal" %}
{% bootstrap_field form.date_to layout="horizontal" %}
{% bootstrap_field form.location layout="horizontal" %}
{% bootstrap_field form.date_admission layout="horizontal" %}
{% bootstrap_field form.currency layout="horizontal" %}
{% bootstrap_field form.is_public layout="horizontal" %}
</fieldset>
@@ -5,7 +5,7 @@
{% block content %}
<h1>{% trans "Settings" %}</h1>
<ul class="nav nav-pills">
{% if request.eventperm.can_change_settings %}
{% if 'can_change_event_settings' in request.eventpermset %}
<li {% if "event.settings" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "General" %}
@@ -41,8 +41,6 @@
{% trans "Invoicing" %}
</a>
</li>
{% endif %}
{% if request.eventperm.can_change_permissions %}
<li {% if "event.settings.permissions" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.settings.permissions' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "Permissions" %}
@@ -10,6 +10,7 @@
{% bootstrap_field form.ticket_download layout="horizontal" %}
{% bootstrap_field form.ticket_download_date layout="horizontal" %}
{% bootstrap_field form.ticket_download_addons layout="horizontal" %}
{% bootstrap_field form.ticket_download_nonadm layout="horizontal" %}
{% for provider in providers %}
<div class="panel panel-default ticketoutput-panel">
<div class="panel-heading">
@@ -1,7 +0,0 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% block content %}
<h1>{% trans "Help center" %}</h1>
{% block inner %}
{% endblock %}
{% endblock %}
@@ -1,68 +0,0 @@
{% extends "pretixcontrol/help/base.html" %}
{% block title %}Payment fee calculation{% endblock %}
{% block inner %}
<h2>Payment fee calculation</h2>
<p>
If you configure a fee for a payment method, there are two possible ways for us to calculate this. Let's
assume that your payment provider, e.g. PayPal, charges you 5 % fees and you want to charge your users the
same 5 %, such that for a ticket with a list price of 100 € you will get your full 100 €.
</p>
<ul>
<li>
<strong>Method A: Calculate the fee from the subtotal and add it to the bill.</strong> For a ticket price of
100 €, this will lead to the following calculation:
<table class="table helper-width-auto">
<tr>
<td>Ticket price</td>
<td class="text-right">100.00 €</td>
</tr>
<tr>
<td>pretix calculates the fee as 5% of 100 €</td>
<td class="text-right">+ 5.00 €</td>
</tr>
<tr>
<td>Subtotal that will be paid by the customer</td>
<td class="text-right">105.00 €</td>
</tr>
<tr>
<td>PayPal calculates its fee as 5% of 105 €</td>
<td class="text-right">- 5.25 €</td>
</tr>
<tr>
<td>End total that is on your bank account</td>
<td class="text-right"><strong>99.75 €</strong></td>
</tr>
</table>
</li>
<li>
<strong>Method B (default): Calculate the fee from the total value including the fee.</strong> For a ticket
price of 100 €, this will lead to the following calculation:
<table class="table helper-width-auto">
<tr>
<td>Ticket price</td>
<td class="text-right">100.00 €</td>
</tr>
<tr>
<td>pretix calculates the fee as 100/(100 - 5)% of 100 €</td>
<td class="text-right">+ 5.26 €</td>
</tr>
<tr>
<td>Subtotal that will be paid by the customer</td>
<td class="text-right">105.26 €</td>
</tr>
<tr>
<td>PayPal calculates its fee as 5% of 105.26 €</td>
<td class="text-right">- 5.26 €</td>
</tr>
<tr>
<td>End total that is on your bank account</td>
<td class="text-right"><strong>100.00 €</strong></td>
</tr>
</table>
<div class="alert-warning alert">
Due to the various rounding steps performed by us and by the payment provider, the end total on
your bank account might stil vary by one cent.
</div>
</li>
</ul>
{% endblock %}
@@ -24,6 +24,7 @@
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
@@ -31,8 +32,12 @@
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Add-On" %}</h3>
</div>
<div class="col-sm-4">
<button type="button" class="btn btn-xs btn-danger pull-right" data-formset-delete-button>
<div class="col-sm-4 text-right">
<button type="button" class="btn btn-xs btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-xs btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
@@ -51,6 +56,7 @@
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
@@ -59,7 +65,11 @@
<h3 class="panel-title">{% trans "Add-On" %}</h3>
</div>
<div class="col-sm-4">
<button type="button" class="btn btn-xs btn-danger pull-right" data-formset-delete-button>
<button type="button" class="btn btn-xs btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-xs btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
@@ -37,6 +37,7 @@
{% bootstrap_form_errors form %}
{% bootstrap_field form.active layout='horizontal' %}
{% bootstrap_field form.default_price layout='horizontal' %}
{% bootstrap_field form.description layout='horizontal' %}
</div>
</div>
{% endfor %}
@@ -69,6 +70,7 @@
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.active layout='horizontal' %}
{% bootstrap_field formset.empty_form.default_price layout='horizontal' %}
{% bootstrap_field formset.empty_form.description layout='horizontal' %}
</div>
</div>
{% endescapescript %}
@@ -6,7 +6,7 @@
{% block inside %}
<h1>
{% blocktrans with name=quota.name %}Quota: {{ name }}{% endblocktrans %}
{% if request.eventperm.can_change_items %}
{% if 'can_change_items' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" event=request.event.slug organizer=request.event.organizer.slug quota=quota.pk %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
@@ -14,7 +14,7 @@
{% endblocktrans %}
{% include "pretixcontrol/orders/fragment_order_status.html" with order=order class="pull-right" %}
</h1>
{% if request.eventperm.can_change_orders %}
{% if 'can_change_orders' in request.eventpermset %}
{% if order.status == 'n' or order.status == 'p' or order.status == 'e' %}
<form action="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"
method="post">
@@ -151,7 +151,7 @@
<div class="panel panel-default items">
<div class="panel-heading">
<div class="pull-right">
{% if order.changable and request.eventperm.can_change_orders %}
{% if order.changable and 'can_change_orders' in request.eventpermset %}
<a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change products" %}
@@ -5,11 +5,13 @@
{% block content %}
<h1>
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
{% if 'can_change_organizer_settings' in request.orgapermset %}
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
{% endif %}
</h1>
<ul class="nav nav-pills">
<li {% if "organizer" == url_name %}class="active"{% endif %}>
@@ -17,10 +19,10 @@
{% trans "Events" %}
</a>
</li>
{% if request.orgaperm.can_change_permissions %}
<li {% if "organizer.teams" == url_name %}class="active"{% endif %}>
{% if 'can_change_teams' in request.orgapermset %}
<li {% if "organizer.team" in url_name %}class="active"{% endif %}>
<a href="{% url "control:organizer.teams" organizer=organizer.slug %}">
{% trans "Permissions" %}
{% trans "Teams" %}
</a>
</li>
{% endif %}
@@ -4,17 +4,22 @@
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "Organizer" %}</h1>
<form action="" method="post" class="form-horizontal">
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_form_errors form %}
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.slug layout="horizontal" %}
{% if form.domain %}
{% bootstrap_field form.domain layout="horizontal" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Display settings" %}</legend>
{% bootstrap_form_errors sform %}
{% bootstrap_field sform.organizer_logo_image layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -0,0 +1,29 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h2>{% trans "Delete team:" %} {{ team.name }}</h2>
{% if not possible %}
<p>{% blocktrans %}You cannot delete the team because there would be noone left who could change team permissions afterwards.{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:organizer.teams" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<div class="clearfix"></div>
</div>
{% else %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the team?{% endblocktrans %}
</p>
<div class="form-group submit-group">
<a href="{% url "control:organizer.teams" organizer=request.organizer.slug%}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endif %}
{% endblock %}
@@ -0,0 +1,47 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
{% if team %}
<h2>{% trans "Team:" %} {{ team.name }}</h2>
{% else %}
<h2>{% trans "Create a new team" %}</h2>
<p>
{% blocktrans trimmed %}
You will be able to add team members in the next step.
{% endblocktrans %}
</p>
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Organizer permissions" %}</legend>
{% bootstrap_field form.can_create_events layout="horizontal" %}
{% bootstrap_field form.can_change_teams layout="horizontal" %}
{% bootstrap_field form.can_change_organizer_settings layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Event permissions" %}</legend>
{% bootstrap_field form.all_events layout="horizontal" %}
{% bootstrap_field form.limit_events layout="horizontal" %}
{% bootstrap_field form.can_change_event_settings layout="horizontal" %}
{% bootstrap_field form.can_change_items layout="horizontal" %}
{% bootstrap_field form.can_view_orders layout="horizontal" %}
{% bootstrap_field form.can_change_orders layout="horizontal" %}
{% bootstrap_field form.can_view_vouchers layout="horizontal" %}
{% bootstrap_field form.can_change_vouchers layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,82 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h2>
{% trans "Team:" %} {{ team.name }}
<a href="{% url "control:organizer.team.edit" organizer=organizer.slug team=team.pk %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</h2>
<form action="" method="post">
{% csrf_token %}
<!-- Trick browsers into taking this as a default -->
<button type="submit" class="btn btn-primary btn-sm btn-block nearly-gone"></button>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in team.members.all %}
<tr>
<td>
{{ u.email }}
</td>
<td class="text-right">
<button type="submit" name="remove-member" value="{{ u.id }}"
class="btn btn-danger btn-sm btn-block">
<i class="fa fa-times"></i> {% trans "Remove" %}
</button>
</td>
</tr>
{% endfor %}
{% for i in team.invites.all %}
<tr>
<td>
{{ i.email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
</td>
<td class="text-right">
<button type="submit" name="remove-invite" value="{{ i.id }}"
class="btn btn-danger btn-sm btn-block">
<i class="fa fa-times"></i> {% trans "Remove" %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td>
{% bootstrap_field add_form.user layout='inline' %}<br>
{% blocktrans trimmed %}
To add a new user, you can enter their email address here. If they already have a
pretix account, they will immediately be added to the event. Otherwise, they will
be sent an email with an invitation.
{% endblocktrans %}
</td>
<td class="text-right">
<button type="submit" class="btn btn-primary btn-sm btn-block">
<i class="fa fa-plus"></i> {% trans "Add" %}
</button>
</td>
</tr>
</tfoot>
</table>
</form>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Team history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=team %}
</div>
{% endblock %}
@@ -2,83 +2,57 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<form action="" method="post" class="form-horizontal form-permissions">
{% csrf_token %}
<p>
{% blocktrans trimmed %}
You can use the following list to control who can create new events in the name of
this organizer and who can add more people to this list. This does <strong>not</strong>
control who has access to a particular event. You can control the access to an
event in the "Permissions" section of the event's settings. A user does not need to
be on the list here to get access to an event.
{% endblocktrans %}
</p>
<p>
{% trans "Everyone on this list can control the organizer settings on this page." %}
</p>
{% bootstrap_formset_errors formset %}
{{ formset.management_form }}
<div class="table-responsive">
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "User" %}</th>
<th>{% trans "Create events" %}</th>
<th>{% trans "Change permissions" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>
{{ form.id }}
{% if form.instance.user %}
{{ form.instance.user }}
{% else %}
{{ form.instance.invite_email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
{% endif %}
</td>
<td>{{ form.can_create_events }}</td>
<td>{{ form.can_change_permissions }}</td>
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="9">
<strong>{% trans "Adding a new user" %}</strong><br>
{% blocktrans trimmed %}
To add a new user, you can enter their email address here. If they
already have a pretix account, they will immediately be added to the team.
Otherwise, they will be sent an email with an invitation.
<p>
{% trans "The list below shows all teams that exist within this organizer." %}
</p>
{% if request.user.is_superuser %}
<a href="{% url "control:organizer.team.add" organizer=request.organizer.slug %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new team" %}
</a>
{% endif %}
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Team name" %}</th>
<th>{% trans "Members" %}</th>
<th>{% trans "Events" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in teams %}
<tr>
<td><strong>
<a href="{% url "control:organizer.team" organizer=request.organizer.slug team=t.id %}">
{{ t.name }}
</a>
</strong></td>
<td>
{{ t.memcount }}
{% if t.invcount %}
{% blocktrans trimmed with count=t.invcount %}
+ {{ count }} invited
{% endblocktrans %}
</td>
</tr>
<tr>
<td>
<div class="row-fluid">
<div class="col-sm-12">
{% bootstrap_field add_form.user layout='inline' %}
</div>
</div>
</td>
<td>{{ add_form.can_create_events }}</td>
<td>{{ add_form.can_change_permissions }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endif %}
</td>
<td>
{% if t.all_events %}
{% trans "All" %}
{% else %}
{{ t.eventcount }}
{% endif %}
</td>
<td class="text-right">
<a href="{% url "control:organizer.team" organizer=request.organizer.slug team=t.id %}"
class="btn btn-default btn-sm"><i class="fa fa-list"></i></a>
<a href="{% url "control:organizer.team.edit" organizer=request.organizer.slug team=t.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.team.delete" organizer=request.organizer.slug team=t.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -11,7 +11,7 @@
</div>
{% endif %}
<div class="row">
{% if request.eventperm.can_change_orders %}
{% if 'can_change_orders' in request.eventpermset %}
<form method="post" class="col-md-6"
action="{% url "control:event.orders.waitinglist.auto" event=request.event.slug organizer=request.organizer.slug %}"
data-asynctask>
@@ -48,7 +48,7 @@
</div>
</form>
{% endif %}
<div class="{% if request.eventperm.can_change_orders %}col-md-6{% else %}col-md-12{% endif %}">
<div class="{% if 'can_change_orders' in request.eventpermset %}col-md-6{% else %}col-md-12{% endif %}">
<div class="panel panel-default">
<div class="panel-heading">
{% trans "Sales estimate" %}
+10 -3
View File
@@ -1,7 +1,7 @@
from django.conf.urls import include, url
from pretix.control.views import (
auth, dashboards, event, global_settings, help, item, main, orders,
auth, checkin, dashboards, event, global_settings, item, main, orders,
organizer, user, vouchers, waitinglist,
)
@@ -35,7 +35,14 @@ urlpatterns = [
url(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.OrganizerTeamView.as_view(), name='organizer.teams'),
url(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
url(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
url(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
name='organizer.team'),
url(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/edit$', organizer.TeamUpdateView.as_view(),
name='organizer.team.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/delete$', organizer.TeamDeleteView.as_view(),
name='organizer.team.delete'),
url(r'^events/$', main.EventList.as_view(), name='events'),
url(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
url(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
@@ -131,6 +138,6 @@ urlpatterns = [
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'),
url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
url(r'^checkins/$', checkin.CheckInView.as_view(), name='event.orders.checkins'),
])),
url(r'^help/(?P<topic>[a-zA-Z0-9_/]+)$', help.HelpView.as_view(), name='help'),
]
+34 -34
View File
@@ -9,6 +9,7 @@ from django.contrib.auth import (
)
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
@@ -23,9 +24,7 @@ from u2flib_server.utils import rand_bytes
from pretix.base.forms.auth import (
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
)
from pretix.base.models import (
EventPermission, OrganizerPermission, U2FDevice, User,
)
from pretix.base.models import TeamInvite, U2FDevice, User
from pretix.base.services.mail import SendMailException, mail
from pretix.helpers.urls import build_absolute_uri
@@ -108,35 +107,30 @@ def invite(request, token):
ctx = {}
try:
perm = EventPermission.objects.get(invite_token=token)
desc = perm.event.name
except EventPermission.DoesNotExist:
try:
perm = OrganizerPermission.objects.get(invite_token=token)
desc = perm.organizer.name
except OrganizerPermission.DoesNotExist:
messages.error(request, _('You used an invalid link. Please copy the link from your email to the address bar '
'and make sure it is correct and that the link has not been used before.'))
return redirect('control:auth.login')
inv = TeamInvite.objects.get(token=token)
except TeamInvite.DoesNotExist:
messages.error(request, _('You used an invalid link. Please copy the link from your email to the address bar '
'and make sure it is correct and that the link has not been used before.'))
return redirect('control:auth.login')
if request.user.is_authenticated:
try:
if isinstance(perm, EventPermission):
EventPermission.objects.get(event=perm.event, user=request.user)
else:
OrganizerPermission.objects.get(organizer=perm.organizer, user=request.user)
if inv.team.members.filter(pk=request.user.pk).exists():
messages.error(request, _('You cannot accept the invitation for "{}" as you already are part of '
'this team.').format(desc))
'this team.').format(inv.team.name))
return redirect('control:index')
else:
with transaction.atomic():
inv.team.members.add(request.user)
inv.team.log_action(
'pretix.team.member.joined', data={
'email': request.user.email,
'invite_email': inv.email,
'user': request.user.pk
}
)
inv.delete()
messages.success(request, _('You are now part of the team "{}".').format(inv.team.name))
return redirect('control:index')
except (EventPermission.DoesNotExist, OrganizerPermission.DoesNotExist):
pass
perm.invite_token = None
perm.invite_email = None
perm.user = request.user
perm.save()
messages.success(request, _('You have now access to "{}".').format(desc))
return redirect('control:index')
if request.method == 'POST':
form = RegistrationForm(data=request.POST)
@@ -151,14 +145,20 @@ def invite(request, token):
auth_login(request, user)
request.session['pretix_auth_login_time'] = int(time.time())
perm.invite_token = None
perm.invite_email = None
perm.user = user
perm.save()
messages.success(request, _('Welcome to pretix! You have now access to "{}".').format(desc))
with transaction.atomic():
inv.team.members.add(request.user)
inv.team.log_action(
'pretix.team.member.joined', data={
'email': user.email,
'invite_email': inv.email,
'user': user.pk
}
)
inv.delete()
messages.success(request, _('Welcome to pretix! You are now part of the team "{}".').format(inv.team.name))
return redirect('control:index')
else:
form = RegistrationForm(initial={'email': perm.invite_email})
form = RegistrationForm(initial={'email': inv.email})
ctx['form'] = form
return render(request, 'pretixcontrol/auth/invite.html', ctx)
+79
View File
@@ -0,0 +1,79 @@
from django.db.models import Prefetch, Q
from django.db.models.functions import Coalesce
from django.views.generic import ListView
from pretix.base.models import Checkin, Item, OrderPosition
from pretix.control.permissions import EventPermissionRequiredMixin
class CheckInView(EventPermissionRequiredMixin, ListView):
model = Checkin
context_object_name = 'entries'
paginate_by = 30
template_name = 'pretixcontrol/checkin/index.html'
permission = 'can_view_orders'
def get_queryset(self):
qs = OrderPosition.objects.filter(order__event=self.request.event, order__status='p')
# if this setting is False, we check only items for admission
if not self.request.event.settings.ticket_download_nonadm:
qs = qs.filter(item__admission=True)
if self.request.GET.get("status", "") != "":
p = self.request.GET.get("status", "")
if p == '1':
# records with check-in record
qs = qs.filter(checkins__isnull=False)
elif p == '0':
qs = qs.filter(checkins__isnull=True)
if self.request.GET.get("user", "") != "":
u = self.request.GET.get("user", "")
qs = qs.filter(
Q(order__email__icontains=u) | Q(attendee_name__icontains=u) | Q(attendee_email__icontains=u)
)
if self.request.GET.get("item", "") != "":
u = self.request.GET.get("item", "")
qs = qs.filter(item_id__in=(u,))
qs = qs.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.filter(position__order__event=self.request.event))
).select_related('order', 'item', 'addon_to')
if self.request.GET.get("ordering", "") != "":
p = self.request.GET.get("ordering", "")
keys_allowed = self.get_ordering_keys_mappings()
if p in keys_allowed:
mapped_field = keys_allowed[p]
if type(mapped_field) is tuple:
qs = qs.annotate(**mapped_field[1]).order_by(mapped_field[0])
else:
qs = qs.order_by(mapped_field)
return qs.distinct()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['items'] = Item.objects.filter(event=self.request.event)
ctx['filtered'] = ("status" in self.request.GET or "user" in self.request.GET or "item" in self.request.GET)
return ctx
@staticmethod
def get_ordering_keys_mappings():
return {
'code': 'order__code',
'-code': '-order__code',
'email': 'order__email',
'-email': '-order__email',
'status': 'checkins__id',
'-status': '-checkins__id',
'timestamp': 'checkins__datetime',
'-timestamp': '-checkins__datetime',
'item': 'item__name',
'-item': '-item__name',
'name': ('display_name', {'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}),
'-name': ('-display_name', {'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}),
}
+28 -5
View File
@@ -13,7 +13,7 @@ from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import (
Event, Item, Order, OrderPosition, Voucher, WaitingListEntry,
Item, Order, OrderPosition, Voucher, WaitingListEntry,
)
from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets,
@@ -168,6 +168,27 @@ def shop_state_widget(sender, **kwargs):
}]
@receiver(signal=event_dashboard_widgets)
def checkin_widget(sender, **kwargs):
size_qs = OrderPosition.objects.filter(order__event=sender, order__status='p')
checked_qs = OrderPosition.objects.filter(order__event=sender, order__status='p', checkins__isnull=False)
# if this setting is False, we check only items for admission
if not sender.settings.ticket_download_nonadm:
size_qs = size_qs.filter(item__admission=True)
checked_qs = checked_qs.filter(item__admission=True)
return [{
'content': NUM_WIDGET.format(num='{}/{}'.format(checked_qs.count(), size_qs.count()), text=_('Checked in')),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.checkins', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
}]
@receiver(signal=event_dashboard_widgets)
def welcome_wizard_widget(sender, **kwargs):
template = get_template('pretixcontrol/event/dashboard_widget_welcome.html')
@@ -207,11 +228,12 @@ def event_index(request, organizer, event):
for r, result in event_dashboard_widgets.send(sender=request.event):
widgets.extend(result)
can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders')
qs = request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST)
if not request.eventperm.can_view_orders:
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders'):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order))
if not request.eventperm.can_view_vouchers:
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers'):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
a_qs = request.event.requiredaction_set.filter(done=False)
@@ -221,7 +243,7 @@ def event_index(request, organizer, event):
ctx = {
'widgets': rearrange(widgets),
'logs': qs[:5],
'actions': a_qs[:5] if request.eventperm.can_change_orders else [],
'actions': a_qs[:5] if can_change_orders else [],
'has_domain': has_domain
}
@@ -242,7 +264,8 @@ def event_index(request, organizer, event):
def user_event_widgets(**kwargs):
user = kwargs.pop('user')
widgets = []
events = Event.objects.filter(permitted__id__exact=user.pk).select_related("organizer").order_by('-date_from')
events = user.get_events_with_any_permission().order_by('-date_from', 'name').select_related('organizer')
for event in events:
widgets.append({
'content': '<div class="event">{event}<span class="from">{df}</span><span class="to">{dt}</span></div>'.format(
+24 -151
View File
@@ -2,14 +2,12 @@ import re
from collections import OrderedDict
from datetime import timedelta
from django import forms
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.files import File
from django.core.urlresolvers import reverse
from django.db import transaction
from django.forms import modelformset_factory
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils import translation
@@ -22,14 +20,12 @@ from django.views.generic.base import TemplateView, View
from django.views.generic.detail import SingleObjectMixin
from pytz import timezone
from pretix.base.forms import I18nModelForm
from pretix.base.models import (
CachedTicket, Event, EventPermission, Item, ItemVariation, LogEntry, Order,
RequiredAction, User, Voucher,
CachedTicket, Event, Item, ItemVariation, LogEntry, Order, RequiredAction,
Voucher,
)
from pretix.base.services import tickets
from pretix.base.services.invoices import build_preview_invoice_pdf
from pretix.base.services.mail import SendMailException, mail
from pretix.base.signals import (
event_live_issues, register_payment_providers, register_ticket_outputs,
)
@@ -50,7 +46,7 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView):
model = Event
form_class = EventUpdateForm
template_name = 'pretixcontrol/event/settings.html'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
@cached_property
def object(self) -> Event:
@@ -115,7 +111,7 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView):
class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Event
context_object_name = 'event'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/plugins.html'
def get_object(self, queryset=None) -> Event:
@@ -178,7 +174,7 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin
class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Event
context_object_name = 'event'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/payment.html'
def get_object(self, queryset=None) -> Event:
@@ -264,7 +260,7 @@ class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMi
class EventSettingsFormView(EventPermissionRequiredMixin, FormView):
model = Event
permission = 'can_change_settings'
permission = 'can_change_event_settings'
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
@@ -300,7 +296,7 @@ class InvoiceSettings(EventSettingsFormView):
model = Event
form_class = InvoiceSettingsForm
template_name = 'pretixcontrol/event/invoicing.html'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
def get_success_url(self) -> str:
if 'preview' in self.request.POST:
@@ -315,12 +311,13 @@ class InvoiceSettings(EventSettingsFormView):
class InvoicePreview(EventPermissionRequiredMixin, View):
permission = 'can_change_settings'
permission = 'can_change_event_settings'
def get(self, request, *args, **kwargs):
pdf = build_preview_invoice_pdf(request.event)
resp = HttpResponse(pdf, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="invoice-preview.pdf"'
resp['Content-Security-Policy'] = "style-src 'unsafe-inline'; script-src 'unsafe-inline'; object-src 'self'"
resp['Content-Disposition'] = 'inline; filename="invoice-preview.pdf"'
return resp
@@ -328,7 +325,7 @@ class DisplaySettings(EventSettingsFormView):
model = Event
form_class = DisplaySettingsForm
template_name = 'pretixcontrol/event/display.html'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
def get_success_url(self) -> str:
return reverse('control:event.settings.display', kwargs={
@@ -364,7 +361,7 @@ class MailSettings(EventSettingsFormView):
model = Event
form_class = MailSettingsForm
template_name = 'pretixcontrol/event/mail.html'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
def get_success_url(self) -> str:
return reverse('control:event.settings.mail', kwargs={
@@ -407,7 +404,7 @@ class MailSettings(EventSettingsFormView):
class MailSettingsPreview(EventPermissionRequiredMixin, View):
permission = 'can_change_settings'
permission = 'can_change_event_settings'
# return the origin text if key is missing in dict
class SafeDict(dict):
@@ -513,7 +510,7 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
class TicketSettingsPreview(EventPermissionRequiredMixin, View):
permission = 'can_change_settings'
permission = 'can_change_event_settings'
@cached_property
def output(self):
@@ -531,7 +528,11 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
fname, mimet, data = tickets.preview(self.request.event.pk, self.output.identifier)
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
if mimet == "application/pdf":
resp['Content-Security-Policy'] = "style-src 'unsafe-inline'; script-src 'unsafe-inline'; object-src 'self'"
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
else:
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
return resp
def get_error_url(self) -> str:
@@ -545,7 +546,7 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
model = Event
form_class = TicketSettingsForm
template_name = 'pretixcontrol/event/tickets.html'
permission = 'can_change_settings'
permission = 'can_change_event_settings'
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
@@ -637,140 +638,12 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
return providers
class EventPermissionForm(I18nModelForm):
class Meta:
model = EventPermission
fields = (
'can_change_settings', 'can_change_items', 'can_change_permissions', 'can_view_orders',
'can_change_orders', 'can_view_vouchers', 'can_change_vouchers'
)
class EventPermissionCreateForm(EventPermissionForm):
user = forms.EmailField(required=False, label=_('User'))
class EventPermissions(EventPermissionRequiredMixin, TemplateView):
model = Event
form_class = TicketSettingsForm
template_name = 'pretixcontrol/event/permissions.html'
permission = 'can_change_permissions'
@cached_property
def formset(self):
fs = modelformset_factory(
EventPermission,
form=EventPermissionForm,
can_delete=True, can_order=False, extra=0
)
return fs(data=self.request.POST if self.request.method == "POST" else None,
prefix="formset",
queryset=EventPermission.objects.filter(event=self.request.event))
@cached_property
def add_form(self):
return EventPermissionCreateForm(data=self.request.POST if self.request.method == "POST" else None,
prefix="add")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
ctx['add_form'] = self.add_form
return ctx
def _send_invite(self, instance):
try:
mail(
instance.invite_email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'event': self.request.event.name,
'url': build_absolute_uri('control:auth.invite', kwargs={
'token': instance.invite_token
})
},
event=None,
locale=self.request.LANGUAGE_CODE
)
except SendMailException:
pass # Already logged
@transaction.atomic
def post(self, *args, **kwargs):
if self.formset.is_valid() and self.add_form.is_valid():
if self.add_form.has_changed():
logdata = {
k: v for k, v in self.add_form.cleaned_data.items()
}
try:
self.add_form.instance.event = self.request.event
self.add_form.instance.event_id = self.request.event.id
self.add_form.instance.user = User.objects.get(email=self.add_form.cleaned_data['user'])
self.add_form.instance.user_id = self.add_form.instance.user.id
except User.DoesNotExist:
self.add_form.instance.invite_email = self.add_form.cleaned_data['user']
if EventPermission.objects.filter(invite_email=self.add_form.instance.invite_email,
event=self.request.event).exists():
messages.error(self.request, _('This user already has been invited for this event.'))
return self.get(*args, **kwargs)
self.add_form.save()
self._send_invite(self.add_form.instance)
self.request.event.log_action(
'pretix.event.permissions.invited', user=self.request.user, data=logdata
)
else:
if EventPermission.objects.filter(user=self.add_form.instance.user,
event=self.request.event).exists():
messages.error(self.request, _('This user already has permissions for this event.'))
return self.get(*args, **kwargs)
self.add_form.save()
logdata['user'] = self.add_form.instance.user_id
self.request.event.log_action(
'pretix.event.permissions.added', user=self.request.user, data=logdata
)
for form in self.formset.forms:
if form.has_changed():
changedata = {
k: form.cleaned_data.get(k) for k in form.changed_data
}
changedata['user'] = form.instance.user_id
self.request.event.log_action(
'pretix.event.permissions.changed', user=self.request.user, data=changedata
)
if form.instance.user_id == self.request.user.pk:
if not form.cleaned_data['can_change_permissions'] or form in self.formset.deleted_forms:
messages.error(self.request, _('You cannot remove your own permission to view this page.'))
return self.get(*args, **kwargs)
for form in self.formset.deleted_forms:
logdata = {
k: v for k, v in form.cleaned_data.items()
}
self.request.event.log_action(
'pretix.event.permissions.deleted', user=self.request.user, data=logdata
)
self.formset.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('Your changes could not be saved.'))
return self.get(*args, **kwargs)
def get_success_url(self) -> str:
return reverse('control:event.settings.permissions', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})
class EventLive(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_settings'
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/live.html'
def get_context_data(self, **kwargs):
@@ -840,9 +713,9 @@ class EventLog(EventPermissionRequiredMixin, ListView):
def get_queryset(self):
qs = self.request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST)
if not self.request.eventperm.can_view_orders:
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders'):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order))
if not self.request.eventperm.can_view_vouchers:
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_vouchers'):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
if self.request.GET.get('user') == 'yes':
@@ -856,7 +729,7 @@ class EventLog(EventPermissionRequiredMixin, ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['userlist'] = self.request.event.user_perms.select_related('user')
ctx['userlist'] = self.request.event.logentry_set.order_by().distinct().values('user__id', 'user__email')
return ctx
-27
View File
@@ -1,27 +0,0 @@
from django import template
from django.http import Http404
from django.shortcuts import render
from django.views.generic import View
from pretix.base.models import Organizer
class HelpView(View):
model = Organizer
context_object_name = 'organizers'
template_name = 'pretixcontrol/organizers/index.html'
paginate_by = 30
def get(self, request, *args, **kwargs):
# In a security review, this came up as a possible path traversal issue. However, the URL regex
# does not allow any dots in the argument (which forbids traversing upwards in the directory tree).
# Even if it *was* possbile, it'd be loaded through django's template loader and therefore limited
# to TEMPLATE_DIR.
try:
locale = request.LANGUAGE_CODE
return render(request, 'pretixcontrol/help/%s.%s.html' % (kwargs.get('topic'), locale), {})
except template.TemplateDoesNotExist:
try:
return render(request, 'pretixcontrol/help/%s.html' % kwargs.get('topic'), {})
except template.TemplateDoesNotExist:
raise Http404('')
+10 -7
View File
@@ -4,7 +4,7 @@ from django.contrib import messages
from django.core.files import File
from django.core.urlresolvers import resolve, reverse
from django.db import transaction
from django.db.models import Count, F, Q
from django.db.models import Count, F, Max, Q
from django.forms.models import ModelMultipleChoiceField, inlineformset_factory
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import redirect
@@ -72,7 +72,7 @@ def item_move(request, item, up=True):
if item.position != i:
item.position = i
item.save()
messages.success(request, _('The order of items as been updated.'))
messages.success(request, _('The order of items has been updated.'))
@event_permission_required("can_change_items")
@@ -803,6 +803,8 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
form.instance.min_per_order = form.cleaned_data['copy_from'].min_per_order
form.instance.max_per_order = form.cleaned_data['copy_from'].max_per_order
form.instance.position = (self.request.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
ret = super().form_valid(form)
form.instance.log_action('pretix.event.item.added', user=self.request.user, data={
k: (form.cleaned_data.get(k).name
@@ -943,7 +945,7 @@ class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
formsetclass = inlineformset_factory(
Item, ItemAddOn,
form=ItemAddOnForm, formset=ItemAddOnsFormSet,
can_order=False, can_delete=True, extra=0
can_order=True, can_delete=True, extra=0
)
return formsetclass(self.request.POST if self.request.method == "POST" else None,
queryset=ItemAddOn.objects.filter(base_item=self.get_object()),
@@ -963,12 +965,13 @@ class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
form.instance.delete()
form.instance.pk = None
forms = [
ef for ef in self.formset.extra_forms + self.formset.initial_forms
if ef not in self.formset.deleted_forms
forms = self.formset.ordered_forms + [
ef for ef in self.formset.extra_forms
if ef not in self.formset.ordered_forms and ef not in self.formset.deleted_forms
]
for i, form in enumerate(forms):
form.instance.base_item = self.get_object()
form.instance.position = i
created = not form.instance.pk
form.save()
if form.has_changed():
@@ -986,7 +989,7 @@ class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
def get(self, request, *args, **kwargs):
if self.get_object().category and self.get_object().category.is_addon:
messages.error(self.request, _('Your cannot add addons to a product that is only available as an add-on '
messages.error(self.request, _('You cannot add addons to a product that is only available as an add-on '
'itself.'))
return redirect(self.get_previous_url())
+20 -17
View File
@@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _
from django.views.generic import ListView
from formtools.wizard.views import SessionWizardView
from pretix.base.models import Event, EventPermission, OrganizerPermission
from pretix.base.models import Event, Team
from pretix.control.forms.event import (
EventWizardBasicsForm, EventWizardCopyForm, EventWizardFoundationForm,
)
@@ -20,22 +20,13 @@ class EventList(ListView):
template_name = 'pretixcontrol/events/index.html'
def get_queryset(self):
if self.request.user.is_superuser:
return Event.objects.all().select_related("organizer").prefetch_related(
"_settings_objects", "organizer___settings_objects"
)
else:
return Event.objects.filter(
permitted__id__exact=self.request.user.pk
).select_related("organizer").prefetch_related(
"_settings_objects", "organizer___settings_objects"
)
return self.request.user.get_events_with_any_permission().select_related('organizer').prefetch_related(
'_settings_objects', 'organizer___settings_objects'
)
def condition_copy(wizard):
return EventPermission.objects.filter(
user=wizard.request.user, can_change_settings=True, can_change_items=True
).exists()
return EventWizardCopyForm.copy_from_queryset(wizard.request.user).exists()
class EventWizard(SessionWizardView):
@@ -55,8 +46,7 @@ class EventWizard(SessionWizardView):
def get_context_data(self, form, **kwargs):
ctx = super().get_context_data(form, **kwargs)
ctx['has_organizer'] = OrganizerPermission.objects.filter(user=self.request.user,
can_create_events=True).exists()
ctx['has_organizer'] = self.request.user.teams.filter(can_create_events=True).exists()
return ctx
def get_form_kwargs(self, step=None):
@@ -81,7 +71,20 @@ class EventWizard(SessionWizardView):
event.organizer = foundation_data['organizer']
event.plugins = settings.PRETIX_PLUGINS_DEFAULT
form_dict['basics'].save()
EventPermission.objects.create(event=event, user=self.request.user)
has_control_rights = self.request.user.teams.filter(
organizer=event.organizer, all_events=True, can_change_event_settings=True, can_change_items=True,
can_change_orders=True, can_change_vouchers=True
).exists()
if not has_control_rights:
t = Team.objects.create(
organizer=event.organizer, name=_('Team {event}').format(event=event.name),
can_change_event_settings=True, can_change_items=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True,
can_change_vouchers=True
)
t.members.add(self.request.user)
t.limit_events.add(event)
logdata = {}
for f in form_list:
+315 -147
View File
@@ -1,18 +1,22 @@
from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.core.urlresolvers import reverse
from django.db import transaction
from django.forms import modelformset_factory
from django.shortcuts import redirect
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from django.views.generic import (
CreateView, DeleteView, DetailView, ListView, UpdateView,
)
from pretix.base.forms import I18nModelForm
from pretix.base.models import Organizer, OrganizerPermission, User
from pretix.base.models import Organizer, Team, TeamInvite, User
from pretix.base.services.mail import SendMailException, mail
from pretix.control.forms.organizer import OrganizerForm, OrganizerUpdateForm
from pretix.control.forms.organizer import (
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
)
from pretix.control.permissions import OrganizerPermissionRequiredMixin
from pretix.control.signals import nav_organizer
from pretix.helpers.urls import build_absolute_uri
@@ -28,25 +32,14 @@ class OrganizerList(ListView):
if self.request.user.is_superuser:
return Organizer.objects.all()
else:
return Organizer.objects.filter(
permitted__id__exact=self.request.user.pk
)
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
class OrganizerPermissionForm(I18nModelForm):
class Meta:
model = OrganizerPermission
fields = (
'can_create_events', 'can_change_permissions'
)
class OrganizerPermissionCreateForm(OrganizerPermissionForm):
class InviteForm(forms.Form):
user = forms.EmailField(required=False, label=_('User'))
class OrganizerDetailViewMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['nav_organizer'] = []
@@ -82,141 +75,55 @@ class OrganizerTeamView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMix
permission = 'can_change_permissions'
context_object_name = 'organizer'
@cached_property
def formset(self):
fs = modelformset_factory(
OrganizerPermission,
form=OrganizerPermissionForm,
can_delete=True, can_order=False, extra=0
)
return fs(
data=(
self.request.POST
if self.request.method == "POST" and 'formset-TOTAL_FORMS' in self.request.POST
else None
),
prefix="formset",
queryset=OrganizerPermission.objects.filter(organizer=self.request.organizer)
)
@cached_property
def add_form(self):
return OrganizerPermissionCreateForm(
data=(
self.request.POST
if self.request.method == "POST" and 'formset-TOTAL_FORMS' in self.request.POST
else None
),
prefix="add"
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
ctx['add_form'] = self.add_form
return ctx
def _send_invite(self, instance):
try:
mail(
instance.invite_email,
_('pretix account invitation'),
'pretixcontrol/email/invitation_organizer.txt',
{
'user': self,
'organizer': self.request.organizer.name,
'url': build_absolute_uri('control:auth.invite', kwargs={
'token': instance.invite_token
})
},
event=None,
locale=self.request.LANGUAGE_CODE
)
except SendMailException:
pass # Already logged
@transaction.atomic
def post(self, *args, **kwargs):
if self.formset.is_valid() and self.add_form.is_valid():
if self.add_form.has_changed():
logdata = {
k: v for k, v in self.add_form.cleaned_data.items()
}
try:
self.add_form.instance.organizer = self.request.organizer
self.add_form.instance.organizer_id = self.request.organizer.id
self.add_form.instance.user = User.objects.get(email=self.add_form.cleaned_data['user'])
self.add_form.instance.user_id = self.add_form.instance.user.id
except User.DoesNotExist:
self.add_form.instance.invite_email = self.add_form.cleaned_data['user']
if OrganizerPermission.objects.filter(invite_email=self.add_form.instance.invite_email,
organizer=self.request.organizer).exists():
messages.error(self.request, _('This user already has been invited for this team.'))
return self.get(*args, **kwargs)
self.add_form.save()
self._send_invite(self.add_form.instance)
self.request.organizer.log_action(
'pretix.organizer.permissions.invited', user=self.request.user, data=logdata
)
else:
if OrganizerPermission.objects.filter(user=self.add_form.instance.user,
organizer=self.request.organizer).exists():
messages.error(self.request, _('This user already has permissions for this team.'))
return self.get(*args, **kwargs)
self.add_form.save()
logdata['user'] = self.add_form.instance.user_id
self.request.organizer.log_action(
'pretix.organizer.permissions.added', user=self.request.user, data=logdata
)
for form in self.formset.forms:
if form.has_changed():
changedata = {
k: form.cleaned_data.get(k) for k in form.changed_data
}
changedata['user'] = form.instance.user_id
self.request.organizer.log_action(
'pretix.organizer.permissions.changed', user=self.request.user, data=changedata
)
if form.instance.user_id == self.request.user.pk:
if not form.cleaned_data['can_change_permissions'] or form in self.formset.deleted_forms:
messages.error(self.request, _('You cannot remove your own permission to view this page.'))
return self.get(*args, **kwargs)
for form in self.formset.deleted_forms:
logdata = {
k: v for k, v in form.cleaned_data.items()
}
self.request.organizer.log_action(
'pretix.organizer.permissions.deleted', user=self.request.user, data=logdata
)
self.formset.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('Your changes could not be saved.'))
return self.get(*args, **kwargs)
def get_success_url(self) -> str:
return reverse('control:organizer.teams', kwargs={
'organizer': self.request.organizer.slug,
})
class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
model = Organizer
form_class = OrganizerUpdateForm
template_name = 'pretixcontrol/organizers/edit.html'
permission = None
permission = 'can_change_organizer_settings'
context_object_name = 'organizer'
def get_object(self, queryset=None) -> Organizer:
@cached_property
def object(self) -> Organizer:
return self.request.organizer
def get_object(self, queryset=None) -> Organizer:
return self.object
@cached_property
def sform(self):
return OrganizerSettingsForm(
obj=self.object,
prefix='settings',
data=self.request.POST if self.request.method == 'POST' else None,
files=self.request.FILES if self.request.method == 'POST' else None
)
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['sform'] = self.sform
return context
@transaction.atomic
def form_valid(self, form):
self.sform.save()
if self.sform.has_changed():
self.request.organizer.log_action(
'pretix.organizer.settings',
user=self.request.user,
data={
k: (self.sform.cleaned_data.get(k).name
if isinstance(self.sform.cleaned_data.get(k), File)
else self.sform.cleaned_data.get(k))
for k in self.sform.changed_data
}
)
if form.has_changed():
self.request.organizer.log_action(
'pretix.organizer.changed',
user=self.request.user,
data={k: form.cleaned_data.get(k) for k in form.changed_data}
)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
@@ -231,6 +138,13 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
'organizer': self.request.organizer.slug,
})
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and self.sform.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
class OrganizerCreate(CreateView):
model = Organizer
@@ -243,14 +157,268 @@ class OrganizerCreate(CreateView):
raise PermissionDenied() # TODO
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
messages.success(self.request, _('The new organizer has been created.'))
ret = super().form_valid(form)
OrganizerPermission.objects.create(
organizer=form.instance, user=self.request.user,
can_create_events=True
t = Team.objects.create(
organizer=form.instance, name=_('Administrators'),
all_events=True, can_create_events=True, can_change_teams=True,
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
)
t.members.add(self.request.user)
return ret
def get_success_url(self) -> str:
return reverse('control:organizers')
class TeamListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = Team
template_name = 'pretixcontrol/organizers/teams.html'
permission = 'can_change_teams'
context_object_name = 'teams'
def get_queryset(self):
return self.request.organizer.teams.annotate(
memcount=Count('members', distinct=True),
eventcount=Count('limit_events', distinct=True),
invcount=Count('invites', distinct=True)
).all()
class TeamCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
model = Team
template_name = 'pretixcontrol/organizers/team_edit.html'
permission = 'can_change_teams'
form_class = TeamForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['organizer'] = self.request.organizer
return kwargs
def get_object(self, queryset=None):
return get_object_or_404(Team, organizer=self.request.organizer, pk=self.kwargs.get('team'))
def get_success_url(self):
return reverse('control:organizer.team', kwargs={
'organizer': self.request.organizer.slug,
'team': self.object.pk
})
def form_valid(self, form):
messages.success(self.request, _('The team has been created. You can now add members to the team.'))
form.instance.organizer = self.request.organizer
ret = super().form_valid(form)
form.instance.members.add(self.request.user)
form.instance.log_action('pretix.team.created', user=self.request.user, data={
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
for k in form.changed_data
})
return ret
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class TeamUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
model = Team
template_name = 'pretixcontrol/organizers/team_edit.html'
permission = 'can_change_teams'
context_object_name = 'team'
form_class = TeamForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['organizer'] = self.request.organizer
return kwargs
def get_object(self, queryset=None):
return get_object_or_404(Team, organizer=self.request.organizer, pk=self.kwargs.get('team'))
def get_success_url(self):
return reverse('control:organizer.team', kwargs={
'organizer': self.request.organizer.slug,
'team': self.object.pk
})
def form_valid(self, form):
if form.has_changed():
self.object.log_action('pretix.team.changed', user=self.request.user, data={
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class TeamDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
model = Team
template_name = 'pretixcontrol/organizers/team_delete.html'
permission = 'can_change_teams'
context_object_name = 'team'
def get_object(self, queryset=None):
return get_object_or_404(Team, organizer=self.request.organizer, pk=self.kwargs.get('team'))
def get_success_url(self):
return reverse('control:organizer.teams', kwargs={
'organizer': self.request.organizer.slug,
})
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['possible'] = self.is_allowed()
return context
def is_allowed(self) -> bool:
return self.request.organizer.teams.exclude(pk=self.kwargs.get('team')).filter(
can_change_teams=True, members__isnull=False
).exists()
@transaction.atomic
def delete(self, request, *args, **kwargs):
success_url = self.get_success_url()
self.object = self.get_object()
if self.is_allowed():
self.object.log_action('pretix.team.deleted', user=self.request.user)
self.object.delete()
messages.success(request, _('The selected team has been deleted.'))
return redirect(success_url)
else:
messages.error(request, _('The selected team cannot be deleted.'))
return redirect(success_url)
class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
template_name = 'pretixcontrol/organizers/team_members.html'
context_object_name = 'team'
permission = 'can_change_teams'
model = Team
def get_object(self, queryset=None):
return get_object_or_404(Team, organizer=self.request.organizer, pk=self.kwargs.get('team'))
@cached_property
def add_form(self):
return InviteForm(data=self.request.POST if self.request.method == "POST" else None)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['add_form'] = self.add_form
return ctx
def _send_invite(self, instance):
try:
mail(
instance.email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'organizer': self.request.organizer.name,
'team': instance.team.name,
'url': build_absolute_uri('control:auth.invite', kwargs={
'token': instance.token
})
},
event=None,
locale=self.request.LANGUAGE_CODE
)
except SendMailException:
pass # Already logged
@transaction.atomic
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if 'remove-member' in request.POST:
try:
user = User.objects.get(pk=request.POST.get('remove-member'))
except User.DoesNotExist:
pass
else:
other_admin_teams = self.request.organizer.teams.exclude(pk=self.object.pk).filter(
can_change_teams=True, members__isnull=False
).exists()
if not other_admin_teams and self.object.can_change_teams and self.object.members.count() == 1:
messages.error(self.request, _('You cannot remove the last member from this team as noone would '
'be left with the permission to change teams.'))
return redirect(self.get_success_url())
else:
self.object.members.remove(user)
self.object.log_action(
'pretix.team.member.removed', user=self.request.user, data={
'email': user.email,
'user': user.pk
}
)
messages.success(self.request, _('The member has been removed from the team.'))
return redirect(self.get_success_url())
elif 'remove-invite' in request.POST:
try:
invite = self.object.invites.get(pk=request.POST.get('remove-invite'))
except TeamInvite.DoesNotExist:
messages.error(self.request, _('Invalid invite selected.'))
return redirect(self.get_success_url())
else:
invite.delete()
self.object.log_action(
'pretix.team.invite.deleted', user=self.request.user, data={
'email': invite.email
}
)
messages.success(self.request, _('The invite has been revoked.'))
return redirect(self.get_success_url())
elif self.add_form.is_valid() and self.add_form.has_changed():
try:
user = User.objects.get(email=self.add_form.cleaned_data['user'])
except User.DoesNotExist:
if self.object.invites.filter(email=self.add_form.cleaned_data['user']).exists():
messages.error(self.request, _('This user already has been invited for this team.'))
return self.get(request, *args, **kwargs)
invite = self.object.invites.create(email=self.add_form.cleaned_data['user'])
self._send_invite(invite)
self.object.log_action(
'pretix.team.invite.created', user=self.request.user, data={
'email': self.add_form.cleaned_data['user']
}
)
messages.success(self.request, _('The new member has been invited to the team.'))
return redirect(self.get_success_url())
else:
if self.object.members.filter(pk=user.pk).exists():
messages.error(self.request, _('This user already has permissions for this team.'))
return self.get(request, *args, **kwargs)
self.object.members.add(user)
self.object.log_action(
'pretix.team.member.added', user=self.request.user,
data={
'email': user.email,
'user': user.pk,
}
)
messages.success(self.request, _('The new member has been added to the team.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('Your changes could not be saved.'))
return self.get(request, *args, **kwargs)
def get_success_url(self) -> str:
return reverse('control:organizer.team', kwargs={
'organizer': self.request.organizer.slug,
'team': self.object.pk
})
+1 -1
View File
@@ -44,7 +44,7 @@ class WaitingListView(EventPermissionRequiredMixin, ListView):
def post(self, request, *args, **kwargs):
if 'assign' in request.POST:
if not request.eventperm.can_change_orders:
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'):
messages.error(request, _('You do not have permission to do this'))
return redirect(reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
+6 -2
View File
@@ -6,12 +6,16 @@ def daterange(df, dt):
lng = get_language()
if lng.startswith("de"):
if df.year == dt.year and df.month == dt.month:
if df.year == dt.year and df.month == dt.month and df.day == dt.day:
return "{}".format(_date(df, "j. F Y"))
elif df.year == dt.year and df.month == dt.month:
return "{}.{}".format(_date(df, "j"), _date(dt, "j. F Y"))
elif df.year == dt.year:
return "{} {}".format(_date(df, "j. F"), _date(dt, "j. F Y"))
elif lng.startswith("en"):
if df.year == dt.year and df.month == dt.month:
if df.year == dt.year and df.month == dt.month and df.day == dt.day:
return "{}".format(_date(df, "N jS, Y"))
elif df.year == dt.year and df.month == dt.month:
return "{} {}".format(_date(df, "N jS"), _date(dt, "jS, Y"))
elif df.year == dt.year:
return "{} {}".format(_date(df, "N jS"), _date(dt, "N jS, Y"))
+1
View File
@@ -1,3 +1,4 @@
# Date according to https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
SHORT_DATE_FORMAT = 'Y-m-d'
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
TIME_FORMAT = 'H:i'
File diff suppressed because it is too large Load Diff
+93 -3
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-17 15:09+0000\n"
"PO-Revision-Date: 2017-04-17 17:10+0200\n"
"POT-Creation-Date: 2017-05-11 10:12+0000\n"
"PO-Revision-Date: 2017-05-11 12:13+0200\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: \n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.8.11\n"
"X-Generator: Poedit 2.0.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:53
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:59
@@ -46,6 +46,96 @@ msgstr "Gesamtumsatz"
msgid "Contacting Stripe …"
msgstr "Kontaktiere Stripe …"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:21
msgid "QR Code"
msgstr "QR-Code"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:157
msgid "Sample product"
msgstr "Beispielprodukt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:158
msgid "Sample variation"
msgstr "Beispielvariante"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:159
msgid "Sample product sample variation"
msgstr "Beispielprodukt Beispielvariante"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:160
msgid "123.45 EUR"
msgstr "123,45 EUR"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:161
msgid "John Doe"
msgstr "Max Mustermann"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:162
msgid "Sample event name"
msgstr "Beispielevent"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:163
msgid "May 31st, 2017"
msgstr "31. Mai 2017"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:164
msgid "20:00"
msgstr "20:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:165
msgid "19:00"
msgstr "19:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:166
msgid "2017-05-31 20:00"
msgstr "31.05.2016 20:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:167
msgid "2017-05-31 19:00"
msgstr "31.05.2016 19:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:168
msgid "Random City"
msgstr "Musterstadt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:213
msgid "The PDF background file could not be loaded for the following reason:"
msgstr "Die Hintergrund-PDF-Datei konnte nicht geladen werden:"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:362
msgid "Group of objects"
msgstr "Gruppe von Objekten"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:368
msgid "Text object"
msgstr "Text-Objekt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:370
msgid "Barcode area"
msgstr "QR-Code-Bereich"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:372
msgid "Object"
msgstr "Objekt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:376
msgid "Ticket design"
msgstr "Ticket-Design"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:612
msgid "Saving failed."
msgstr "Speichern fehlgeschlagen."
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:644
msgid "Do you really want to leave the editor without saving your changes?"
msgstr ""
"Möchten Sie den Editor wirklich schließen ohne Ihre Änderungen zu speichern?"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:657
msgid "Error while uploading your PDF file, please try again."
msgstr ""
"Es gab ein Problem beim Hochladen der PDF-Datei, bitte erneut versuchen."
#: pretix/static/pretixbase/js/asyncdownload.js:28
#: pretix/static/pretixbase/js/asynctask.js:42
#: pretix/static/pretixbase/js/asynctask.js:94
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-17 15:09+0000\n"
"PO-Revision-Date: 2017-04-17 17:09+0200\n"
"POT-Creation-Date: 2017-05-11 10:12+0000\n"
"PO-Revision-Date: 2017-05-11 12:13+0200\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: \n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.8.11\n"
"X-Generator: Poedit 2.0.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:53
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:59
@@ -46,6 +46,96 @@ msgstr "Gesamtumsatz"
msgid "Contacting Stripe …"
msgstr "Kontaktiere Stripe …"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:21
msgid "QR Code"
msgstr "QR-Code"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:157
msgid "Sample product"
msgstr "Beispielprodukt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:158
msgid "Sample variation"
msgstr "Beispielvariante"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:159
msgid "Sample product sample variation"
msgstr "Beispielprodukt Beispielvariante"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:160
msgid "123.45 EUR"
msgstr "123,45 EUR"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:161
msgid "John Doe"
msgstr "Max Mustermann"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:162
msgid "Sample event name"
msgstr "Beispielevent"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:163
msgid "May 31st, 2017"
msgstr "31. Mai 2017"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:164
msgid "20:00"
msgstr "20:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:165
msgid "19:00"
msgstr "19:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:166
msgid "2017-05-31 20:00"
msgstr "31.05.2016 20:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:167
msgid "2017-05-31 19:00"
msgstr "31.05.2016 19:00"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:168
msgid "Random City"
msgstr "Musterstadt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:213
msgid "The PDF background file could not be loaded for the following reason:"
msgstr "Die Hintergrund-PDF-Datei konnte nicht geladen werden:"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:362
msgid "Group of objects"
msgstr "Gruppe von Objekten"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:368
msgid "Text object"
msgstr "Text-Objekt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:370
msgid "Barcode area"
msgstr "QR-Code-Bereich"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:372
msgid "Object"
msgstr "Objekt"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:376
msgid "Ticket design"
msgstr "Ticket-Design"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:612
msgid "Saving failed."
msgstr "Speichern fehlgeschlagen."
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:644
msgid "Do you really want to leave the editor without saving your changes?"
msgstr ""
"Möchtest du den Editor wirklich schließen ohne Ihre Änderungen zu speichern?"
#: pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/editor.js:657
msgid "Error while uploading your PDF file, please try again."
msgstr ""
"Es gab ein Problem beim Hochladen der PDF-Datei, bitte erneut versuchen."
#: pretix/static/pretixbase/js/asyncdownload.js:28
#: pretix/static/pretixbase/js/asynctask.js:42
#: pretix/static/pretixbase/js/asynctask.js:94
+1 -1
View File
@@ -17,7 +17,7 @@ def register_payment_provider(sender, **kwargs):
@receiver(nav_event, dispatch_uid="payment_banktransfer_nav")
def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.eventperm.can_change_orders:
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'):
return []
return [
{
+4
View File
@@ -39,6 +39,10 @@ class Paypal(BasePaymentProvider):
('client_id',
forms.CharField(
label=_('Client ID'),
help_text=_('<a target="_blank" href="{docs_url}">{text}</a>').format(
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html'
)
)),
('secret',
forms.CharField(
+13 -1
View File
@@ -57,7 +57,19 @@ def success(request, *args, **kwargs):
@event_view(require_live=False)
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
if request.session.get('payment_paypal_order'):
order = Order.objects.get(pk=request.session.get('payment_paypal_order'))
else:
order = None
if order:
return redirect(eventreverse(request.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}) + ('?paid=yes' if order.status == Order.STATUS_PAID else ''))
else:
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
@csrf_exempt
+29 -4
View File
@@ -1,7 +1,10 @@
import json
import dateutil.parser
import pytz
from django.core.urlresolvers import resolve, reverse
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
from pretix.base.signals import logentry_display
@@ -11,7 +14,7 @@ from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="pretixdroid_nav")
def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.eventperm.can_change_orders:
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'):
return []
return [
{
@@ -32,11 +35,33 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
return
data = json.loads(logentry.data)
show_dt = False
if 'datetime' in data:
dt = dateutil.parser.parse(data.get('datetime'))
show_dt = abs((logentry.datetime - dt).total_seconds()) > 60 or 'forced' in data
tz = pytz.timezone(sender.settings.timezone)
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if data.get('first'):
return _('Position #{posid} has been scanned.'.format(
posid=data.get('positionid')
))
if show_dt:
return _('Position #{posid} has been scanned at {datetime}.').format(
posid=data.get('positionid'),
datetime=dt_formatted
)
else:
return _('Position #{posid} has been scanned.').format(
posid=data.get('positionid')
)
else:
if data.get('forced'):
return _(
'A scan for position #{posid} at {datetime} has been uploaded even though it has '
'been scanned already.'.format(
posid=data.get('positionid'),
datetime=dt_formatted
)
)
return _('Position #{posid} has been scanned and rejected because it has already been scanned before.'.format(
posid=data.get('positionid')
))
+2
View File
@@ -9,6 +9,8 @@ urlpatterns = [
name='api.redeem'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/search/', views.ApiSearchView.as_view(),
name='api.search'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/download/', views.ApiDownloadView.as_view(),
name='api.download'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/status/', views.ApiStatusView.as_view(),
name='api.status'),
]
+60 -24
View File
@@ -2,6 +2,7 @@ import json
import logging
import string
import dateutil.parser
from django.db import transaction
from django.db.models import Count, Q
from django.http import (
@@ -9,6 +10,7 @@ from django.http import (
)
from django.utils.crypto import get_random_string
from django.utils.decorators import method_decorator
from django.utils.timezone import now
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView, View
@@ -19,7 +21,7 @@ from pretix.multidomain.urlreverse import \
build_absolute_uri as event_absolute_uri
logger = logging.getLogger('pretix.plugins.pretixdroid')
API_VERSION = 2
API_VERSION = 3
class ConfigView(EventPermissionRequiredMixin, TemplateView):
@@ -67,37 +69,55 @@ class ApiView(View):
class ApiRedeemView(ApiView):
def post(self, request, **kwargs):
secret = request.POST.get('secret', '!INVALID!')
force = request.POST.get('force', 'false') in ('true', 'True')
nonce = request.POST.get('nonce')
response = {
'version': API_VERSION
}
if 'datetime' in request.POST:
dt = dateutil.parser.parse(request.POST.get('datetime'))
else:
dt = now()
try:
with transaction.atomic():
created = False
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
order__event=self.event, secret=secret
)
if op.order.status == Order.STATUS_PAID:
ci, created = Checkin.objects.get_or_create(position=op)
if op.order.status == Order.STATUS_PAID or force:
ci, created = Checkin.objects.get_or_create(position=op, defaults={
'datetime': dt,
'nonce': nonce,
})
else:
response['status'] = 'error'
response['reason'] = 'unpaid'
if 'status' not in response:
if created:
if created or (nonce and nonce == ci.nonce):
response['status'] = 'ok'
op.order.log_action('pretix.plugins.pretixdroid.scan', data={
'position': op.id,
'positionid': op.positionid,
'first': True,
})
if created:
op.order.log_action('pretix.plugins.pretixdroid.scan', data={
'position': op.id,
'positionid': op.positionid,
'first': True,
'forced': op.order.status != Order.STATUS_PAID,
'datetime': dt,
})
else:
response['status'] = 'error'
response['reason'] = 'already_redeemed'
if force:
response['status'] = 'ok'
else:
response['status'] = 'error'
response['reason'] = 'already_redeemed'
op.order.log_action('pretix.plugins.pretixdroid.scan', data={
'position': op.id,
'positionid': op.positionid,
'first': False,
'forced': force,
'datetime': dt,
})
response['data'] = {
@@ -115,6 +135,18 @@ class ApiRedeemView(ApiView):
return JsonResponse(response)
def serialize_op(op):
return {
'secret': op.secret,
'order': op.order.code,
'item': str(op.item),
'variation': str(op.variation) if op.variation else None,
'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
'redeemed': bool(op.checkin_cnt),
'paid': op.order.status == Order.STATUS_PAID,
}
class ApiSearchView(ApiView):
def get(self, request, **kwargs):
query = request.GET.get('query', '!INVALID!')
@@ -123,30 +155,34 @@ class ApiSearchView(ApiView):
}
if len(query) >= 4:
ops = OrderPosition.objects.select_related('item', 'variation', 'order').filter(
ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter(
Q(order__event=self.event)
& Q(
Q(secret__istartswith=query) | Q(attendee_name__icontains=query) | Q(order__code__istartswith=query)
)
).prefetch_related('checkins')[:25]
).annotate(checkin_cnt=Count('checkins'))[:25]
response['results'] = [
{
'secret': op.secret,
'order': op.order.code,
'item': str(op.item),
'variation': str(op.variation) if op.variation else None,
'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
'redeemed': bool(op.checkins.all()),
'paid': op.order.status == Order.STATUS_PAID,
} for op in ops
]
response['results'] = [serialize_op(op) for op in ops]
else:
response['results'] = []
return JsonResponse(response)
class ApiDownloadView(ApiView):
def get(self, request, **kwargs):
response = {
'version': API_VERSION
}
ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter(
Q(order__event=self.event)
).annotate(checkin_cnt=Count('checkins'))
response['results'] = [serialize_op(op) for op in ops]
return JsonResponse(response)
class ApiStatusView(ApiView):
def get(self, request, **kwargs):
response = {
+1 -1
View File
@@ -9,7 +9,7 @@ from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="sendmail_nav")
def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.eventperm.can_change_orders:
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'):
return []
return [
{
@@ -29,6 +29,7 @@
<pre>{{ value.message|linebreaksbr }}</pre>
{% endfor %}
</p>
<a href="{% url 'plugins:sendmail:send' organizer=request.event.organizer.slug event=request.event.slug %}?from_log={{ log.id }}">{% trans "Send a new email based on this" %}</a>
</li>
{% endfor %}
</ul>
+78 -46
View File
@@ -4,18 +4,18 @@ from datetime import timedelta
import pytz
from django.contrib import messages
from django.db.models import Q
from django.http import Http404
from django.shortcuts import redirect
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView, ListView
from pretix.base.i18n import language
from pretix.base.models import LogEntry, Order
from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import InvoiceAddress, LogEntry, Order
from pretix.base.services.mail import SendMailException, mail
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.multidomain.urlreverse import build_absolute_uri
from . import forms
logger = logging.getLogger('pretix.plugins.sendmail')
@@ -29,6 +29,21 @@ class SenderView(EventPermissionRequiredMixin, FormView):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
if 'from_log' in self.request.GET:
try:
from_log_id = self.request.GET.get('from_log')
logentry = LogEntry.objects.get(
id=from_log_id,
event=self.request.event,
action_type='pretix.plugins.sendmail.sent'
)
kwargs['initial'] = {
'message': LazyI18nString(logentry.parsed_data['message']),
'subject': LazyI18nString(logentry.parsed_data['subject']),
'sendto': logentry.parsed_data['sendto'],
}
except LogEntry.DoesNotExist:
raise Http404(_('You supplied an invalid log entry ID'))
return kwargs
def form_invalid(self, form):
@@ -49,54 +64,71 @@ class SenderView(EventPermissionRequiredMixin, FormView):
if not orders:
messages.error(self.request, _('There are no orders matching this selection.'))
return self.get(self.request, *self.args, **self.kwargs)
if self.request.POST.get("action") == "preview":
for l in self.request.event.settings.locales:
with language(l):
self.output[l] = []
self.output[l].append(
_('Subject: {subject}').format(subject=form.cleaned_data['subject'].localize(l)))
message = form.cleaned_data['message'].localize(l)
preview_text = message.format(
order='ORDER1234',
event=self.request.event.name,
order_date=date_format(now(), 'SHORT_DATE_FORMAT'),
due_date=date_format(now() + timedelta(days=7), 'SHORT_DATE_FORMAT'),
order_url=build_absolute_uri(self.request.event, 'presale:event.order', kwargs={
'order': 'ORDER1234',
'secret': 'longrandomsecretabcdef123456'
}),
invoice_name=_('John Doe'),
invoice_company=_('Sample Company LLC'),
)
self.output[l].append(preview_text)
return self.get(self.request, *self.args, **self.kwargs)
for o in orders:
if self.request.POST.get("action") == "preview":
for l in self.request.event.settings.locales:
with language(l):
self.output[l] = []
self.output[l].append(_('Subject: {subject}').format(subject=form.cleaned_data['subject'].localize(l)))
message = form.cleaned_data['message'].localize(l)
preview_text = message.format(
order='ORDER1234',
event=self.request.event.name,
order_date=date_format(now(), 'SHORT_DATE_FORMAT'),
due_date=date_format(now() + timedelta(days=7), 'SHORT_DATE_FORMAT'),
order_url=build_absolute_uri(self.request.event, 'presale:event.order', kwargs={
'order': 'ORDER1234',
'secret': 'longrandomsecretabcdef123456'
}))
self.output[l].append(preview_text)
return self.get(self.request, *self.args, **self.kwargs)
else:
try:
with language(o.locale):
mail(o.email, form.cleaned_data['subject'], form.cleaned_data['message'],
{
'event': o.event,
'order': o.code,
'order_date': date_format(o.datetime.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
'due_date': date_format(o.expires, 'SHORT_DATE_FORMAT'),
'order_url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code,
'secret': o.secret
})},
self.request.event, locale=o.locale, order=o)
o.log_action(
'pretix.plugins.sendmail.order.email.sent',
user=self.request.user,
data={
'subject': form.cleaned_data['subject'],
'message': form.cleaned_data['message'],
'recipient': o.email
}
)
except SendMailException:
failures.append(o.email)
try:
invoice_name = o.invoice_address.name
invoice_company = o.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
try:
with language(o.locale):
mail(
o.email, form.cleaned_data['subject'], form.cleaned_data['message'],
{
'event': o.event,
'order': o.code,
'order_date': date_format(o.datetime.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
'due_date': date_format(o.expires, 'SHORT_DATE_FORMAT'),
'order_url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code,
'secret': o.secret
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
},
self.request.event, locale=o.locale, order=o)
o.log_action(
'pretix.plugins.sendmail.order.email.sent',
user=self.request.user,
data={
'subject': form.cleaned_data['subject'],
'message': form.cleaned_data['message'],
'recipient': o.email
}
)
except SendMailException:
failures.append(o.email)
self.request.event.log_action('pretix.plugins.sendmail.sent',
user=self.request.user,
data=dict(form.cleaned_data))
if failures:
messages.error(self.request, _('Failed to send mails to the following users: {}'.format(' '.join(failures))))
messages.error(self.request,
_('Failed to send mails to the following users: {}'.format(' '.join(failures))))
else:
messages.success(self.request, _('Your message has been queued and will be sent to the selected users.'))
+1 -1
View File
@@ -9,7 +9,7 @@ from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="statistics_nav")
def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.eventperm.can_view_orders:
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders'):
return []
return [
{
+4
View File
@@ -28,6 +28,10 @@ class Stripe(BasePaymentProvider):
('secret_key',
forms.CharField(
label=_('Secret key'),
help_text=_('<a target="_blank" href="{docs_url}">{text}</a>').format(
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/stripe.html'
)
)),
('publishable_key',
forms.CharField(
+48 -1
View File
@@ -1,9 +1,56 @@
from django.dispatch import receiver
from django.dispatch import Signal, receiver
from django.template.loader import get_template
from django.urls import resolve
from pretix.base.signals import register_ticket_outputs
from pretix.control.signals import html_head
@receiver(register_ticket_outputs, dispatch_uid="output_pdf")
def register_ticket_outputs(sender, **kwargs):
from .ticketoutput import PdfTicketOutput
return PdfTicketOutput
@receiver(html_head, dispatch_uid="ticketoutputpdf_html_head")
def html_head_presale(sender, request=None, **kwargs):
url = resolve(request.path_info)
if url.namespace == 'plugins:ticketoutputpdf':
template = get_template('pretixplugins/ticketoutputpdf/control_head.html')
return template.render({
'request': request
})
else:
return ""
register_fonts = Signal()
"""
Return a dictionaries of the following structure. Paths should be relative to static root.
{
"font name": {
"regular": {
"truetype": "….ttf",
"woff": "",
"woff2": ""
},
"bold": {
...
},
"italic": {
...
},
"bolditalic": {
...
}
}
}
"""
def get_fonts():
f = {}
for recv, value in register_fonts.send(0):
f.update(value)
return f
@@ -0,0 +1,41 @@
#fabric-container {
position: absolute;
top: 0;
left: 0;
}
#editor-canvas-area {
position: relative;
min-height: 500px;
}
body {
overflow-y: scroll;
}
#toolbox .control-group {
margin-bottom: 5px;
}
#toolbox .position, #toolbox .squaresize, #toolbox[data-type] .pdf-info, #toolbox .text, #toolbox .object-buttons {
display: none;
}
#toolbox[data-type] .position, #toolbox[data-type=barcodearea] .squaresize, #toolbox[data-type=text] .text, #toolbox[data-type=textarea] .text,
#toolbox[data-type] .object-buttons {
display: block;
}
#loading-container {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: white;
width: 100%;
text-align: center;
}
#loading-container > div {
max-width: 600px;
margin: auto;
}
#loading-upload {
display: none;
}
.preload-font {
visibility: hidden;
}
@@ -0,0 +1,711 @@
/*globals $, gettext, fabric, PDFJS*/
fabric.Barcodearea = fabric.util.createClass(fabric.Rect, {
type: 'barcodearea',
initialize: function (text, options) {
options || (options = {});
this.callSuper('initialize', text, options);
this.set('label', options.label || '');
},
toObject: function () {
return fabric.util.object.extend(this.callSuper('toObject'), {});
},
_render: function (ctx) {
this.callSuper('_render', ctx);
ctx.font = '16px Helvetica';
ctx.fillStyle = '#fff';
ctx.fillText(gettext('QR Code'), -this.width / 2, -this.height / 2 + 20);
},
});
fabric.Barcodearea.fromObject = function (object, callback, forceAsync) {
return fabric.Object._fromObject('Barcodearea', object, callback, forceAsync);
};
fabric.Textarea = fabric.util.createClass(fabric.Textbox, {
type: 'textarea',
initialize: function (text, options) {
options || (options = {});
this.callSuper('initialize', text, options);
this.set('content', options.content || '');
},
toObject: function(propertiesToInclude) {
return this.callSuper('toObject', ['content'].concat(propertiesToInclude));
}
});
fabric.Textarea.fromObject = function (object, callback, forceAsync) {
return fabric.Object._fromObject('Textarea', object, callback, forceAsync, 'text');
};
var editor = {
$pdfcv: null,
$fcv: null,
$cva: null,
$fabric: null,
objects: [],
history: [],
clipboard: [],
pdf_page: null,
pdf_scale: 1,
pdf_viewport: null,
_history_pos: 0,
_history_modification_in_progress: false,
dirty: false,
pdf_url: null,
uploaded_file_id: null,
_window_loaded: false,
_fabric_loaded: false,
_px2mm: function (v) {
return v / editor.pdf_scale / 72 * editor.pdf_page.userUnit * 25.4;
},
_mm2px: function (v) {
return v * editor.pdf_scale * 72 / editor.pdf_page.userUnit / 25.4;
},
_px2pt: function (v) {
return v / editor.pdf_scale * editor.pdf_page.userUnit;
},
_pt2px: function (v) {
return v * editor.pdf_scale / editor.pdf_page.userUnit;
},
dump: function (objs) {
var d = [];
objs = objs || editor.fabric.getObjects();
for (var i in objs) {
var o = objs[i];
if (o.type === "textarea") {
var col = (new fabric.Color(o.getFill()))._source;
d.push({
type: "textarea",
left: editor._px2mm(o.left).toFixed(2),
bottom: editor._px2mm(editor.pdf_viewport.height - o.height - o.top).toFixed(2),
fontsize: editor._px2pt(o.getFontSize()).toFixed(1),
color: col,
//lineheight: o.lineHeight,
fontfamily: o.fontFamily,
bold: o.fontWeight === 'bold',
italic: o.fontStyle === 'italic',
width: editor._px2mm(o.width).toFixed(2),
content: o.content,
text: o.text,
align: o.textAlign,
});
} else if (o.type === "barcodearea") {
d.push({
type: "barcodearea",
left: editor._px2mm(o.left).toFixed(2),
bottom: editor._px2mm(editor.pdf_viewport.height - o.height * o.scaleY - o.top).toFixed(2),
size: editor._px2mm(o.height * o.scaleY).toFixed(2)
});
}
}
return d;
},
_add_from_data: function (d) {
if (d.type === "barcodearea") {
o = editor._add_qrcode();
o.scaleToHeight(editor._mm2px(d.size));
} else if (d.type === "textarea" || o.type === "text") {
o = editor._add_text();
o.setColor('rgb(' + d.color[0] + ',' + d.color[1] + ',' + d.color[2] + ')');
o.setFontSize(editor._pt2px(d.fontsize));
//o.setLineHeight(d.lineheight);
o.setFontFamily(d.fontfamily);
o.setFontWeight(d.bold ? 'bold' : 'normal');
o.setFontStyle(d.italic ? 'italic' : 'normal');
o.setWidth(editor._mm2px(d.width));
o.content = d.content;
o.setTextAlign(d.align);
if (d.content === "other") {
o.setText(d.text);
} else {
o.setText(editor.text_samples[d.content]);
}
}
var new_top = editor.pdf_viewport.height - editor._mm2px(d.bottom) - (o.height * o.scaleY);
o.set('left', editor._mm2px(d.left));
o.set('top', new_top);
o.setCoords();
return o;
},
load: function(data) {
editor.fabric.clear();
for (var i in data) {
var d = data[i], o;
editor._add_from_data(d);
}
editor.fabric.renderAll();
editor._update_toolbox_values();
},
text_samples: {
"secret": "tdmruoekvkpbv1o2mv8xccvqcikvr58u",
"order": "A1B2C",
"item": gettext("Sample product"),
"variation": gettext("Sample variation"),
"itemvar": gettext("Sample product sample variation"),
"price": gettext("123.45 EUR"),
"attendee_name": gettext("John Doe"),
"event_name": gettext("Sample event name"),
"event_date": gettext("May 31st, 2017"),
"event_begin_time": gettext("20:00"),
"event_admission_time": gettext("19:00"),
"event_begin": gettext("2017-05-31 20:00"),
"event_admission": gettext("2017-05-31 19:00"),
"event_location": gettext("Random City")
},
_load_pdf: function (dump) {
// TODO: Loading indicators
var url = editor.pdf_url;
// TODO: Handle cross-origin issues if static files are on a different origin
PDFJS.workerSrc = editor.$pdfcv.attr("data-worker-url");
// Asynchronous download of PDF
var loadingTask = PDFJS.getDocument(url);
loadingTask.promise.then(function (pdf) {
console.log('PDF loaded');
// Fetch the first page
var pageNumber = 1;
pdf.getPage(pageNumber).then(function (page) {
console.log('Page loaded');
var canvas = document.getElementById('pdf-canvas');
var scale = editor.$cva.width() / page.getViewport(1.0).width;
var viewport = page.getViewport(scale);
// Prepare canvas using PDF page dimensions
var context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
canvas.height = viewport.height;
canvas.width = viewport.width;
editor.pdf_page = page;
editor.pdf_scale = scale;
editor.pdf_viewport = viewport;
// Render PDF page into canvas context
var renderContext = {
canvasContext: context,
viewport: viewport
};
var renderTask = page.render(renderContext);
renderTask.then(function () {
console.log('Page rendered');
editor._init_fabric(dump);
});
});
}, function (reason) {
var msg = gettext('The PDF background file could not be loaded for the following reason:');
editor._error(msg + ' ' + reason);
});
},
_init_fabric: function (dump) {
editor.$fcv.get(0).width = editor.$pdfcv.get(0).width;
editor.$fcv.get(0).height = editor.$pdfcv.get(0).height;
editor.fabric = new fabric.Canvas('fabric-canvas');
editor.fabric.on('object:modified', editor._create_savepoint);
editor.fabric.on('object:added', editor._create_savepoint);
editor.fabric.on('selection:cleared', editor._update_toolbox);
editor.fabric.on('selection:created', editor._update_toolbox);
editor.fabric.on('object:selected', editor._update_toolbox);
editor.fabric.on('object:moving', editor._update_toolbox_values);
editor.fabric.on('object:modified', editor._update_toolbox_values);
editor.fabric.on('object:scaling', editor._update_toolbox_values);
editor._update_toolbox();
$("#toolbox-content-other").hide();
$(".add-buttons button").prop('disabled', false);
if (dump) {
editor.load(dump);
} else {
var data = $.trim($("#editor-data").text());
if (data) {
editor.load(JSON.parse(data));
}
}
editor.history = [];
editor._create_savepoint();
editor.dirty = !!dump;
if ($("#loading-upload").is(":visible")) {
$("#loading-container, #loading-upload").hide();
}
editor._fabric_loaded = true;
console.log("Fabric loaded");
if (editor._window_loaded) {
editor._ready();
}
},
_window_load_event: function () {
editor._window_loaded = true;
console.log("Window loaded");
if (editor._fabric_loaded) {
editor._ready();
}
},
_ready: function () {
$("#editor-loading").hide();
$("#editor-start").removeClass("sr-only");
$("#editor-start").click(function () {
$("#loading-container").hide();
$("#loading-initial").remove();
});
},
_update_toolbox_values: function () {
var o = editor.fabric.getActiveObject();
if (!o) {
o = editor.fabric.getActiveGroup();
if (!o) {
return;
}
}
$("#toolbox-position-x").val(editor._px2mm(o.left).toFixed(2));
$("#toolbox-position-y").val(editor._px2mm(editor.pdf_viewport.height - o.height * o.scaleY - o.top).toFixed(2));
if (o.type === "barcodearea") {
$("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2));
} else if (o.type === "text" || o.type === "textarea") {
var col = (new fabric.Color(o.getFill()))._source;
$("#toolbox-col").val("#" + ((1 << 24) + (col[0] << 16) + (col[1] << 8) + col[2]).toString(16).slice(1));
$("#toolbox-fontsize").val(editor._px2pt(o.fontSize).toFixed(1));
//$("#toolbox-lineheight").val(o.lineHeight);
$("#toolbox-fontfamily").val(o.fontFamily);
$("#toolbox").find("button[data-action=bold]").toggleClass('active', o.fontWeight === 'bold');
$("#toolbox").find("button[data-action=italic]").toggleClass('active', o.fontStyle === 'italic');
$("#toolbox").find("button[data-action=left]").toggleClass('active', o.textAlign === 'left');
$("#toolbox").find("button[data-action=center]").toggleClass('active', o.textAlign === 'center');
$("#toolbox").find("button[data-action=right]").toggleClass('active', o.textAlign === 'right');
$("#toolbox-textwidth").val(editor._px2mm(o.width).toFixed(2));
if (o.type === "textarea") {
$("#toolbox-content").val(o.content);
$("#toolbox-content-other").toggle($("#toolbox-content").val() === "other");
if (o.content === "other") {
$("#toolbox-content-other").val(o.text);
} else {
$("#toolbox-content-other").val("");
}
}
}
},
_update_values_from_toolbox: function () {
var o = editor.fabric.getActiveObject();
if (!o) {
o = editor.fabric.getActiveGroup();
if (!o) {
return;
}
}
var new_top = editor.pdf_viewport.height - editor._mm2px($("#toolbox-position-y").val()) - o.height * o.scaleY;
o.set('left', editor._mm2px($("#toolbox-position-x").val()));
o.set('top', new_top);
if (o.type === "barcodearea") {
var new_h = editor._mm2px($("#toolbox-squaresize").val());
new_top += o.height * o.scaleY - new_h;
o.setHeight(new_h);
o.setWidth(new_h);
o.setScaleX(1);
o.setScaleY(1);
o.set('top', new_top)
} else if (o.type === "textarea" || o.type === "text") {
o.setColor($("#toolbox-col").val());
o.setFontSize(editor._pt2px($("#toolbox-fontsize").val()));
//o.setLineHeight($("#toolbox-lineheight").val());
o.setFontFamily($("#toolbox-fontfamily").val());
o.setFontWeight($("#toolbox").find("button[data-action=bold]").is('.active') ? 'bold' : 'normal');
o.setFontStyle($("#toolbox").find("button[data-action=italic]").is('.active') ? 'italic' : 'normal');
var align = $("#toolbox-align").find(".active").attr("data-action");
if (align) {
o.setTextAlign(align);
}
o.setWidth(editor._mm2px($("#toolbox-textwidth").val()));
$("#toolbox-content-other").toggle($("#toolbox-content").val() === "other");
o.content = $("#toolbox-content").val();
if ($("#toolbox-content").val() === "other") {
o.setText($("#toolbox-content-other").val());
} else {
o.setText(editor.text_samples[$("#toolbox-content").val()]);
}
}
o.setCoords();
editor.fabric.renderAll();
},
_update_toolbox: function () {
if (editor.fabric.getActiveGroup()) {
$("#toolbox").attr("data-type", "group");
$("#toolbox-heading").text(gettext("Group of objects"));
var g = editor.fabric.getActiveGroup();
} else if (editor.fabric.getActiveObject()) {
var o = editor.fabric.getActiveObject();
$("#toolbox").attr("data-type", o.type);
if (o.type === "textarea" || o.type === "text") {
$("#toolbox-heading").text(gettext("Text object"));
} else if (o.type === "barcodearea") {
$("#toolbox-heading").text(gettext("Barcode area"));
} else {
$("#toolbox-heading").text(gettext("Object"));
}
} else {
$("#toolbox").removeAttr("data-type");
$("#toolbox-heading").text(gettext("Ticket design"));
$("#pdf-info-width").val(editor._px2mm(editor.pdf_viewport.width).toFixed(2));
$("#pdf-info-height").val(editor._px2mm(editor.pdf_viewport.height).toFixed(2));
}
editor._update_toolbox_values();
},
_error: function (msg) {
editor.$cva.before("<div class='alert alert-danger'>" + msg + "</div>");
},
_add_text: function () {
var text = new fabric.Textarea(editor.text_samples['item'], {
left: 100,
top: 100,
width: editor._mm2px(50),
lockRotation: true,
fontFamily: 'Open Sans',
lineHeight: 1,
content: 'item',
editable: false,
fontSize: editor._pt2px(13)
});
text.setControlsVisibility({
'tr': false,
'tl': false,
'mt': false,
'br': false,
'bl': false,
'mb': false,
'mr': true,
'ml': true,
'mtr': false
});
editor.fabric.add(text);
editor._create_savepoint();
return text;
},
_add_qrcode: function () {
var rect = new fabric.Barcodearea({
left: 100,
top: 100,
width: 100,
height: 100,
lockRotation: true,
lockUniScaling: true,
fill: '#666',
});
rect.setControlsVisibility({'mtr': false});
editor.fabric.add(rect);
editor._create_savepoint();
return rect;
},
_cut: function () {
editor._history_modification_in_progress = true;
var thing = editor.fabric.getActiveObject() ? editor.fabric.getActiveObject() : editor.fabric.getActiveGroup();
if (thing.type === "group") {
editor.clipboard = editor.dump(thing._objects);
thing.forEachObject(function (o) {
o.remove();
});
thing.remove();
} else {
editor.clipboard = editor.dump([thing]);
thing.remove();
}
editor.fabric.discardActiveGroup();
editor.fabric.discardActiveObject();
editor._history_modification_in_progress = false;
editor._create_savepoint();
},
_copy: function () {
editor._history_modification_in_progress = true;
var thing = editor.fabric.getActiveObject() ? editor.fabric.getActiveObject() : editor.fabric.getActiveGroup();
if (thing.type === "group") {
editor.clipboard = editor.dump(thing._objects);
} else {
editor.clipboard = editor.dump([thing]);
}
editor._history_modification_in_progress = false;
editor._create_savepoint();
},
_paste: function () {
if (editor.clipboard.length < 1) {
return;
}
editor._history_modification_in_progress = true;
var objs = [];
for (var i in editor.clipboard) {
objs.push(editor._add_from_data(editor.clipboard[i]));
}
editor.fabric.discardActiveObject();
editor.fabric.discardActiveGroup();
if (editor.clipboard.length > 1) {
var group = new fabric.Group(objs, {
originX: 'left',
originY: 'top',
left: 100,
top: 100,
});
group.setCoords();
editor.fabric.setActiveGroup(group);
} else {
editor.fabric.setActiveObject(objs[0]);
}
editor._history_modification_in_progress = false;
editor._create_savepoint();
},
_delete: function () {
var thing = editor.fabric.getActiveObject() ? editor.fabric.getActiveObject() : editor.fabric.getActiveGroup();
if (thing.type === "group") {
thing.forEachObject(function (o) {
o.remove();
});
thing.remove();
editor.fabric.discardActiveGroup();
} else {
thing.remove();
editor.fabric.discardActiveObject();
}
editor._create_savepoint();
},
_on_keydown: function (e) {
var step = e.shiftKey ? editor._mm2px(10) : editor._mm2px(1);
var thing = editor.fabric.getActiveObject() ? editor.fabric.getActiveObject() : editor.fabric.getActiveGroup();
switch (e.keyCode) {
case 38: /* Up arrow */
thing.set('top', thing.get('top') - step);
thing.setCoords();
editor._create_savepoint();
break;
case 40: /* Down arrow */
thing.set('top', thing.get('top') + step);
thing.setCoords();
editor._create_savepoint();
break;
case 37: /* Left arrow */
thing.set('left', thing.get('left') - step);
thing.setCoords();
editor._create_savepoint();
break;
case 39: /* Right arrow */
thing.set('left', thing.get('left') + step);
thing.setCoords();
editor._create_savepoint();
break;
case 46: /* Delete */
editor._delete();
break;
case 89: /* Y */
if (e.ctrlKey) {
editor._redo();
}
break;
case 90: /* Z */
if (e.ctrlKey) {
editor._undo();
}
break;
case 88: /* X */
if (e.ctrlKey) {
editor._cut();
}
break;
case 86: /* V */
if (e.ctrlKey) {
editor._paste();
}
break;
case 67: /* C */
if (e.ctrlKey) {
editor._copy();
}
break;
default:
return;
}
e.preventDefault();
editor.fabric.renderAll();
editor._update_toolbox_values();
},
_create_savepoint: function () {
if (editor._history_modification_in_progress) {
return;
}
var state = editor.dump();
if (editor._history_pos > 0) {
editor.history.splice(-1 * editor._history_pos, editor._history_pos);
editor._history_pos = 0;
}
editor.history.push(state);
editor.dirty = true;
},
_undo: function undo() {
if (editor._history_pos < editor.history.length - 1) {
editor._history_modification_in_progress = true;
editor._history_pos += 1;
editor.fabric.clear().renderAll();
editor.load(editor.history[editor.history.length - 1 - editor._history_pos]);
editor._history_modification_in_progress = false;
editor.dirty = true;
}
},
_redo: function redo() {
if (editor._history_pos > 0) {
editor._history_modification_in_progress = true;
editor._history_pos -= 1;
editor.load(editor.history[editor.history.length - 1 - editor._history_pos]);
editor._history_modification_in_progress = false;
editor.dirty = true;
}
},
_save: function () {
$("#editor-save").prop('disabled', true).prepend('<span class="fa fa-cog fa-spin"></span>');
var dump = editor.dump();
$.post(window.location.href, {
'data': JSON.stringify(dump),
'csrfmiddlewaretoken': $("input[name=csrfmiddlewaretoken]").val(),
'background': editor.uploaded_file_id,
}, function (data) {
if (data.status === 'ok') {
$("#editor-save span").remove();
$("#editor-save").prop('disabled', false);
editor.dirty = false;
editor.uploaded_file_id = null;
} else {
alert(gettext('Saving failed.'));
}
}, 'json');
},
_preview: function () {
$("#preview-form input[name=data]").val(JSON.stringify(editor.dump()));
$("#preview-form input[name=background]").val(editor.uploaded_file_id);
$("#preview-form").get(0).submit();
},
_replace_pdf_file: function (url) {
editor.pdf_url = url;
d = editor.dump();
editor.fabric.dispose();
editor._load_pdf(d);
},
init: function () {
editor.$pdfcv = $("#pdf-canvas");
editor.pdf_url = editor.$pdfcv.attr("data-pdf-url");
editor.$fcv = $("#fabric-canvas");
editor.$cva = $("#editor-canvas-area");
editor._load_pdf();
$("#editor-add-qrcode").click(editor._add_qrcode);
$("#editor-add-text").click(editor._add_text);
editor.$cva.get(0).tabIndex = 1000;
editor.$cva.on("keydown", editor._on_keydown);
$("#editor-save").on("click", editor._save);
$("#editor-preview").on("click", editor._preview);
window.onbeforeunload = function () {
if (editor.dirty) {
return gettext("Do you really want to leave the editor without saving your changes?");
}
};
$('#fileupload').fileupload({
url: location.href,
dataType: 'json',
done: function (e, data) {
if (data.result.status === "ok") {
editor.uploaded_file_id = data.result.id;
editor._replace_pdf_file(data.result.url);
} else {
alert(data.result.error || gettext("Error while uploading your PDF file, please try again."));
$("#loading-container, #loading-upload").hide();
}
$("#fileupload").prop('disabled', false);
$(".fileinput-button").removeClass("disabled");
},
add: function (e, data) {
data.formData = {
'csrfmiddlewaretoken': $("input[name=csrfmiddlewaretoken]").val()
};
$("#loading-container, #loading-upload").show();
$("#loading-upload .progress").show();
$('#loading-upload .progress-bar').css('width', 0);
$("#fileupload").prop('disabled', true);
$(".fileinput-button").addClass("disabled");
data.process().done(function () {
data.submit();
});
},
progressall: function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
$('#loading-upload .progress-bar').css('width', progress + '%');
}
}).prop('disabled', !$.support.fileInput).parent().addClass($.support.fileInput ? undefined : 'disabled');
$("#toolbox input[type=number], #toolbox textarea, #toolbox input[type=text]").bind('change keydown keyup' +
' input', editor._update_values_from_toolbox);
$("#toolbox input[type=number], #toolbox textarea, #toolbox input[type=text], #toolbox input[type=radio]").bind('change', editor._create_savepoint);
$("#toolbox label.btn").bind('click change', editor._update_values_from_toolbox);
$("#toolbox select").bind('change', editor._update_values_from_toolbox);
$("#toolbox select").bind('change', editor._create_savepoint);
$("#toolbox button.toggling").bind('click change', function () {
if ($(this).is(".option")) {
$(this).addClass("active");
$(this).parent().siblings().find("button").removeClass("active");
} else {
$(this).toggleClass("active");
}
editor._update_values_from_toolbox();
editor._create_savepoint();
});
$("#toolbox .colorpickerfield").bind('changeColor', editor._update_values_from_toolbox);
$("#toolbox-copy").bind('click', editor._copy);
$("#toolbox-cut").bind('click', editor._cut);
$("#toolbox-delete").bind('click', editor._delete);
$("#toolbox-paste").bind('click', editor._paste);
$("#toolbox-undo").bind('click', editor._undo);
$("#toolbox-redo").bind('click', editor._redo);
}
};
$(function () {
editor.init();
});
$(window).bind('load', editor._window_load_event);
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
{% load staticfiles %}
{% load compress %}
{% compress css %}
<link type="text/css" rel="stylesheet" href="{% static "pretixplugins/ticketoutputpdf/editor.css" %}">
{% endcompress %}
<link type="text/css" rel="stylesheet" href="{% url "plugins:ticketoutputpdf:css" organizer=request.organizer.slug event=request.event.slug %}">
@@ -0,0 +1,22 @@
{% load i18n %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Ticket design" %}
</label>
<div class="col-md-9">
<p>
{% blocktrans trimmed %}
You can customize the ticket design with our PDF ticket editor. There, you can upload a PDF file used
as a background for the tickets and then place various texts and QR codes on the background at the
positions of your choice. The editor is easy to use thanks to its drag-and-drop user interface, but it
requires a modern browser and a decent internet connection.
{% endblocktrans %}
</p>
<p>
<a class="btn btn-default" target="_blank"
href="{% url "plugins:ticketoutputpdf:editor" organizer=request.organizer.slug event=request.event.slug %}">
{% trans "Open the PDF editor in a new tab" %}
</a>
</p>
</div>
</div>
@@ -0,0 +1,326 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "PDF Ticket Editor" %}{% endblock %}
{% block content %}
<h1>{% trans "PDF Ticket Editor" %}</h1>
<script type="application/json" id="editor-data">
{{ layout|safe }}
</script>
<div class="row">
<div class="col-md-9">
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right">
<div class="btn-group">
<button type="button" class="btn btn-default btn-xs" id="toolbox-paste"
title="{% trans "Paste" %}">
<span class="fa fa-paste"></span>
</button>
<button type="button" class="btn btn-default btn-xs" id="toolbox-undo"
title="{% trans "Undo" %}">
<span class="fa fa-undo"></span>
</button>
<button type="button" class="btn btn-default btn-xs" id="toolbox-redo"
title="{% trans "Redo" %}">
<span class="fa fa-repeat"></span>
</button>
</div>
</div>
{% trans "Editor" %}
</div>
<div class="panel-body">
<div id="editor-canvas-area">
<canvas id="pdf-canvas"
{% if request.event.settings.ticketoutput_pdf_background %}
data-pdf-url="{{ request.event.settings.ticketoutput_pdf_background.url }}"
{% else %}
data-pdf-url="{% static "pretixpresale/pdf/ticket_default_a4.pdf" %}"
{% endif %}
data-worker-url="{% static "pretixplugins/ticketoutputpdf/pdf.worker.js" %}">
</canvas>
<div id="fabric-container">
<canvas id="fabric-canvas">
</canvas>
</div>
<div id="loading-container">
<div id="loading-upload">
<span class="fa fa-cog big-rotating-icon"></span>
<p>
{% trans "Uploading new PDF background…" %}
</p>
<div class="progress">
<div class="progress-bar" style="width: 0%;">
</div>
</div>
</div>
<div id="loading-initial">
<h2>{% trans "Welcome to the PDF ticket editor!" %}</h2>
<p>
{% blocktrans trimmed %}
This editor allows you to create a design for the PDF tickets of your event.
You can upload a background PDF and then use this tool to place texts and
a QR code on the ticket.
{% endblocktrans %}
</p>
<p>&nbsp;</p>
<p>
<span class="fa fa-eye fa-2x"></span>
</p>
<p>
{% blocktrans trimmed %}
Please note that the editor can only provide a rough preview. Some details,
for example in text rendering, might look slightly different in the final
tickets. You can use the "Preview" button on the right for a more precise
preview.
{% endblocktrans %}
</p>
<p>&nbsp;</p>
<p>
<span class="fa fa-chrome fa-2x"></span>
<span class="fa fa-firefox fa-2x"></span>
<span class="fa fa-opera fa-2x"></span>
</p>
<p>
{% blocktrans trimmed %}
The editor is tested with recent versions of Google Chome, Mozilla Firefox
and Opera. Other browsers, especially Internet Explorer or Microsoft Edge, might
have problems displaying your background PDF or loading the correct fonts.
{% endblocktrans %}
</p>
<noscript>
<div class="alert alert-danger">
{% blocktrans trimmed %}
The editor requires JavaScript to work. Please enable JavaScript in your
browser to continue.
{% endblocktrans %}
</div>
</noscript>
<p>&nbsp;</p>
<p>
<em id="editor-loading">
<span class="fa fa-cog fa-spin"></span>
{% trans "Loading…" %}
</em>
<button id="editor-start" class="btn btn-primary sr-only">
{% trans "Start editing" %}
</button>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3" id="editor-toolbox-area">
<div class="panel panel-default" id="toolbox">
<div class="panel-heading">
<div class="pull-right object-buttons">
<div class="btn-group">
<button type="button" class="btn btn-default btn-xs" id="toolbox-cut"
title="{% trans "Cut" %}">
<span class="fa fa-cut"></span>
</button>
<button type="button" class="btn btn-default btn-xs" id="toolbox-copy"
title="{% trans "Copy" %}">
<span class="fa fa-copy"></span>
</button>
<button type="button" class="btn btn-danger btn-xs" id="toolbox-delete"
title="{% trans "Delete" %}">
<span class="fa fa-trash"></span>
</button>
</div>
</div>
<span id="toolbox-heading">
{% trans "Loading…" %}
</span>
</div>
<div class="panel-body" id="toolbox-body">
<div class="row control-group pdf-info">
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" id="pdf-info-width" class="input-block-level form-control" disabled>
</div>
<div class="col-sm-6">
<label>{% trans "Height (mm)" %}</label><br>
<input type="number" id="pdf-info-height" class="input-block-level form-control" disabled>
</div>
</div>
<div class="row control-group pdf-info">
<div class="col-sm-12">
<label>{% trans "Background PDF" %}</label><br>
<span class="btn btn-default fileinput-button">
<i class="fa fa-upload"></i>
<span>{% trans "Upload new background" %}</span>
<input id="fileupload" type="file" name="background">
</span>
</div>
</div>
<div class="row control-group position">
<div class="col-sm-6">
<label>{% trans "x (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-position-x">
</div>
<div class="col-sm-6">
<label>{% trans "y (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-position-y">
</div>
</div>
<div class="row control-group squaresize">
<div class="col-sm-12">
<label>{% trans "Size (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-squaresize">
</div>
</div>
<div class="row control-group squaresize">
<div class="col-sm-12">
<p>
{% blocktrans trimmed %}
The final QR code will be slightly smaller because some whitespace is required
for proper scanning.
{% endblocktrans %}
</p>
</div>
</div>
<div class="row control-group text">
<div class="col-sm-6">
<label>{% trans "Font size (pt)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.1"
id="toolbox-fontsize">
</div>
<div class="col-sm-6">
<label>&nbsp;</label><br>
<div class="btn-group btn-group-justified" role="group">
<div class="btn-group" role="group">
<button type="button" class="btn btn-default toggling" data-action="bold">
<span class="fa fa-bold"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default toggling" data-action="italic">
<span class="fa fa-italic"></span>
</button>
</div>
</div>
</div>
</div>
<div class="row control-group text">
<div class="col-sm-6">
<label>{% trans "Text color" %}</label><br>
<input type="text" value="#000000" class="input-block-level form-control colorpickerfield"
id="toolbox-col">
</div>
<div class="col-sm-6">
<label>&nbsp;</label><br>
<div class="btn-group btn-group-justified" id="toolbox-align">
<div class="btn-group" role="group">
<button type="button" class="btn btn-default option toggling" data-action="left">
<span class="fa fa-align-left"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default option toggling" data-action="center">
<span class="fa fa-align-center"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default option toggling" data-action="right">
<span class="fa fa-align-right"></span>
</button>
</div>
</div>
</div>
</div>
<div class="row control-group text">
<div class="col-sm-12">
<label>{% trans "Font" %}</label><br>
<select class="input-block-level form-control" id="toolbox-fontfamily">
<option>Open Sans</option>
{% for family in fonts.keys %}
<option>{{ family }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group text">
<div class="col-sm-12">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-textwidth">
</div>
</div>
<div class="row control-group text">
<div class="col-sm-12">
<label>{% trans "Text content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-content">
<option value="secret">{% trans "Ticket code (barcode content)" %}</option>
<option value="order">{% trans "Order code" %}</option>
<option value="item">{% trans "Product name" %}</option>
<option value="variation">{% trans "Variation name" %}</option>
<option value="itemvar">{% trans "Product name and variation" %}</option>
<option value="price">{% trans "Price" %}</option>
<option value="attendee_name">{% trans "Attendee name" %}</option>
<option value="event_name">{% trans "Event name" %}</option>
<option value="event_date">{% trans "Event date" %}</option>
<option value="event_begin">{% trans "Event begin date and time" %}</option>
<option value="event_begin_time">{% trans "Event begin time" %}</option>
<option value="event_admission">{% trans "Event admission date and time" %}</option>
<option value="event_admission_time">{% trans "Event admission time" %}</option>
<option value="event_location">{% trans "Event location" %}</option>
<option value="other">{% trans "Other…" %}</option>
</select>
<textarea type="text" value="" class="input-block-level form-control"
id="toolbox-content-other"></textarea>
</div>
</div>
</div>
</div>
<div class="editor-toolbox-text panel panel-default">
<div class="panel-heading">
{% trans "Add a new object" %}
</div>
<div class="panel-body add-buttons">
<button class="btn btn-default btn-block" id="editor-add-text" disabled>
<span class="fa fa-font"></span>
{% trans "Text" %}
</button>
<button class="btn btn-default btn-block" id="editor-add-qrcode" disabled>
<span class="fa fa-qrcode"></span>
{% trans "QR code area" %}
</button>
</div>
</div>
<form method="post" action="" id="preview-form" target="_blank">
<div class="form-group submit-group">
{% csrf_token %}
<input type="hidden" value="" name="data">
<input type="hidden" value="" name="background">
<input type="hidden" value="true" name="preview">
<button type="submit" class="btn btn-default btn-lg" id="editor-preview">
{% trans "Preview" %}
</button>
<button type="submit" class="btn btn-primary btn-save" id="editor-save">
{% trans "Save" %}
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript" src="{% static "pretixplugins/ticketoutputpdf/pdf.js" %}"></script>
<script type="text/javascript" src="{% static "pretixplugins/ticketoutputpdf/fabric.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixplugins/ticketoutputpdf/editor.js" %}"></script>
{% for family, styles in fonts.items %}
{% for style, formats in styles.items %}
<span class="preload-font" data-family="{{ family }}" data-style="{{ style }}">
giItT1WQy@!-/#
</span>
{% endfor %}
{% endfor %}
{% endblock %}
@@ -0,0 +1,36 @@
{% load staticfiles %}
{% for family, styles in fonts.items %}
{% for style, formats in styles.items %}
@font-face {
font-family: '{{ family }}';
{% if style == "italic" or style == "bolditalic" %}
font-style: italic;
{% else %}
font-style: normal;
{% endif %}
{% if style == "bold" or style == "bolditalic" %}
font-weight: bold;
{% else %}
font-weight: normal;
{% endif %}
src: {% if "woff2" in formats %}url('{% static formats.woff2 %}') format('woff2'),{% endif %}
{% if "woff" in formats %}url('{% static formats.woff %}') format('woff'),{% endif %}
{% if "truetype" in formats %}url('{% static formats.truetype %}') format('truetype'){% endif %};
}
.preload-font[data-family="{{family}}"][data-style="{{style}}"] {
font-family: '{{ family }}';
{% if style == "italic" or style == "bolditalic" %}
font-style: italic;
{% else %}
font-style: normal;
{% endif %}
{% if style == "bold" or style == "bolditalic" %}
font-weight: bold;
{% else %}
font-weight: normal;
{% endif %}
}
{% endfor %}
{% endfor %}
+287 -133
View File
@@ -1,17 +1,32 @@
import copy
import logging
from collections import OrderedDict
import uuid
from io import BytesIO
from django import forms
from django.contrib.staticfiles import finders
from django.core.files import File
from django.core.files.storage import default_storage
from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.formats import date_format, localize
from django.utils.translation import ugettext_lazy as _
from pytz import timezone
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing
from reportlab.lib.colors import Color
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import getAscentDescent
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.models import Order
from pretix.base.models import Order, OrderPosition
from pretix.base.ticketoutput import BaseTicketOutput
from pretix.control.forms import ExtFileField
from pretix.plugins.ticketoutputpdf.signals import get_fonts
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
@@ -21,71 +36,107 @@ class PdfTicketOutput(BaseTicketOutput):
verbose_name = _('PDF output')
download_button_text = _('PDF')
def _draw_page(self, p, op, order):
from reportlab.graphics.shapes import Drawing
from reportlab.lib import units
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics import renderPDF
def __init__(self, event, override_layout=None, override_background=None):
self.override_layout = override_layout
self.override_background = override_background
super().__init__(event)
event_s = self.settings.get('event_s', default=22, as_type=float)
if event_s:
p.setFont("Helvetica", event_s)
event_x = self.settings.get('event_x', default=15, as_type=float)
event_y = self.settings.get('event_y', default=235, as_type=float)
p.drawString(event_x * units.mm, event_y * units.mm, str(self.event.name))
def _register_fonts(self):
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
order_s = self.settings.get('order_s', default=17, as_type=float)
if order_s:
p.setFont("Helvetica", order_s)
order_x = self.settings.get('order_x', default=15, as_type=float)
order_y = self.settings.get('order_y', default=220, as_type=float)
p.drawString(order_x * units.mm, order_y * units.mm, _('Order code: {code}').format(code=order.code))
for family, styles in get_fonts().items():
print(family, finders.find(styles['regular']['truetype']))
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
name_s = self.settings.get('name_s', default=17, as_type=float)
if name_s:
p.setFont("Helvetica", name_s)
name_x = self.settings.get('name_x', default=15, as_type=float)
name_y = self.settings.get('name_y', default=210, as_type=float)
item = str(op.item.name)
if op.variation:
item += " " + str(op.variation)
p.drawString(name_x * units.mm, name_y * units.mm, item)
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
reqs = float(o['size']) * mm
qrw = QrCodeWidget(op.secret, barLevel='H', barHeight=reqs, barWidth=reqs)
d = Drawing(reqs, reqs)
d.add(qrw)
qr_x = float(o['left']) * mm
qr_y = float(o['bottom']) * mm
renderPDF.draw(d, canvas, qr_x, qr_y)
price_s = self.settings.get('price_s', default=17, as_type=float)
if price_s:
p.setFont("Helvetica", price_s)
price_x = self.settings.get('price_x', default=15, as_type=float)
price_y = self.settings.get('price_y', default=200, as_type=float)
p.drawString(price_x * units.mm, price_y * units.mm, "%s %s" % (str(op.price), self.event.currency))
def _get_text_content(self, op: OrderPosition, order: Order, o: dict):
if o['content'] == 'other':
return o['text'].replace("\n", "<br/>\n")
elif o['content'] == 'order':
return order.code
elif o['content'] == 'item':
return str(op.item)
elif o['content'] == 'secret':
return op.secret
elif o['content'] == 'variation':
return str(op.variation) if op.variation else ''
elif o['content'] == 'itemvar':
return '{} - {}'.format(op.item, op.variation) if op.variation else str(op.item)
elif o['content'] == 'price':
return '{} {}'.format(order.event.currency, localize(op.price))
elif o['content'] == 'attendee_name':
return op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')
elif o['content'] == 'event_name':
return str(order.event)
elif o['content'] == 'event_location':
return str(order.event.location).replace("\n", "<br/>\n")
elif o['content'] == 'event_date':
return order.event.get_date_from_display(show_times=False)
elif o['content'] == 'event_begin':
return order.event.get_date_from_display(show_times=True)
elif o['content'] == 'event_begin_time':
return order.event.get_time_from_display()
elif o['content'] == 'event_admission':
if order.event.date_admission:
tz = timezone(order.event.settings.timezone)
return date_format(order.event.date_admission.astimezone(tz), "SHORT_DATETIME_FORMAT")
elif o['content'] == 'event_admission_time':
if order.event.date_admission:
tz = timezone(order.event.settings.timezone)
return date_format(order.event.date_admission.astimezone(tz), "TIME_FORMAT")
return ''
qr_s = self.settings.get('qr_s', default=80, as_type=float)
if qr_s:
reqs = qr_s * units.mm
qrw = QrCodeWidget(op.secret, barLevel='H')
b = qrw.getBounds()
w = b[2] - b[0]
h = b[3] - b[1]
d = Drawing(reqs, reqs, transform=[reqs / w, 0, 0, reqs / h, 0, 0])
d.add(qrw)
qr_x = self.settings.get('qr_x', default=10, as_type=float)
qr_y = self.settings.get('qr_y', default=120, as_type=float)
renderPDF.draw(d, p, qr_x * units.mm, qr_y * units.mm)
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
font = o['fontfamily']
if o['bold']:
font += ' B'
if o['italic']:
font += ' I'
code_s = self.settings.get('code_s', default=11, as_type=float)
if code_s:
p.setFont("Helvetica", code_s)
code_x = self.settings.get('code_x', default=15, as_type=float)
code_y = self.settings.get('code_y', default=120, as_type=float)
p.drawString(code_x * units.mm, code_y * units.mm, op.secret)
align_map = {
'left': TA_LEFT,
'center': TA_CENTER,
'right': TA_RIGHT
}
style = ParagraphStyle(
name=uuid.uuid4().hex,
fontName=font,
fontSize=float(o['fontsize']),
leading=float(o['fontsize']),
autoLeading="max",
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
alignment=align_map[o['align']]
)
attendee_s = self.settings.get('attendee_s', default=0, as_type=float)
if code_s and op.attendee_name:
p.setFont("Helvetica", attendee_s)
attendee_x = self.settings.get('attendee_x', default=15, as_type=float)
attendee_y = self.settings.get('attendee_y', default=90, as_type=float)
p.drawString(attendee_x * units.mm, attendee_y * units.mm, op.attendee_name)
p = Paragraph(self._get_text_content(op, order, o), style=style)
p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
ad = getAscentDescent(font, float(o['fontsize']))
p.drawOn(canvas, float(o['left']) * mm, float(o['bottom']) * mm - ad[1])
p.showPage()
def _draw_page(self, canvas: Canvas, op: OrderPosition, order: Order):
objs = self.override_layout or self.settings.get('layout', as_type=list) or self._legacy_layout()
for o in objs:
if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
canvas.showPage()
def generate_order(self, order: Order):
buffer = BytesIO()
@@ -93,6 +144,8 @@ class PdfTicketOutput(BaseTicketOutput):
for op in order.positions.all():
if op.addon_to_id and not self.event.settings.ticket_download_addons:
continue
if not op.item.admission and not self.event.settings.ticket_download_nonadm:
continue
self._draw_page(p, op, order)
p.save()
outbuffer = self._render_with_background(buffer)
@@ -111,97 +164,198 @@ class PdfTicketOutput(BaseTicketOutput):
from reportlab.pdfgen import canvas
from reportlab.lib import pagesizes
pagesize = self.settings.get('pagesize', default='A4')
if hasattr(pagesizes, pagesize):
pagesize = getattr(pagesizes, pagesize)
else:
pagesize = pagesizes.A4
orientation = self.settings.get('orientation', default='portrait')
if hasattr(pagesizes, orientation):
pagesize = getattr(pagesizes, orientation)(pagesize)
# Doesn't matter as we'll overpaint it over a background later
pagesize = pagesizes.A4
self._register_fonts()
return canvas.Canvas(buffer, pagesize=pagesize)
def _render_with_background(self, buffer):
def _render_with_background(self, buffer, title=_('Ticket')):
from PyPDF2 import PdfFileWriter, PdfFileReader
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
bg_file = self.settings.get('background', as_type=File)
if isinstance(bg_file, File):
if self.override_background:
bgf = default_storage.open(self.override_background.name, "rb")
elif isinstance(bg_file, File):
bgf = default_storage.open(bg_file.name, "rb")
else:
bgf = open(finders.find('pretixpresale/pdf/ticket_default_a4.pdf'), "rb")
bg_pdf = PdfFileReader(bgf)
for page in new_pdf.pages:
bg_page = copy.copy(bg_pdf.getPage(0))
bg_page.mergePage(page)
output.addPage(bg_page)
output.addMetadata({
'/Title': str(title),
'/Creator': 'pretix',
})
outbuffer = BytesIO()
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer
@property
def settings_form_fields(self) -> dict:
return OrderedDict(
list(super().settings_form_fields.items()) + [
('paper_size',
forms.ChoiceField(
label=_('Paper size'),
choices=(
('A4', 'A4'),
('A5', 'A5'),
('B4', 'B4'),
('B5', 'B5'),
('letter', 'Letter'),
('legal', 'Legal'),
),
required=False
)),
('orientation',
forms.ChoiceField(
label=_('Paper orientation'),
choices=(
('portrait', _('Portrait')),
('landscape', _('Landscape')),
),
required=False
)),
('background',
ExtFileField(
label=_('Background PDF'),
ext_whitelist=(".pdf", ),
required=False
)),
('qr_x', forms.FloatField(label=_('QR-Code x position (mm)'), required=False)),
('qr_y', forms.FloatField(label=_('QR-Code y position (mm)'), required=False)),
('qr_s', forms.FloatField(label=_('QR-Code size (mm)'), required=False)),
('code_x', forms.FloatField(label=_('Ticket code x position (mm)'), required=False)),
('code_y', forms.FloatField(label=_('Ticket code y position (mm)'), required=False)),
('code_s', forms.FloatField(label=_('Ticket code size (mm)'), required=False,
help_text=_('Visible by default, set this to 0 to hide the element.'))),
('order_x', forms.FloatField(label=_('Order x position (mm)'), required=False)),
('order_y', forms.FloatField(label=_('Order y position (mm)'), required=False)),
('order_s', forms.FloatField(label=_('Order size (mm)'), required=False,
help_text=_('Visible by default, set this to 0 to hide the element.'))),
('name_x', forms.FloatField(label=_('Product name x position (mm)'), required=False)),
('name_y', forms.FloatField(label=_('Product name y position (mm)'), required=False)),
('name_s', forms.FloatField(label=_('Product name size (mm)'), required=False,
help_text=_('Visible by default, set this to 0 to hide the element.'))),
('price_x', forms.FloatField(label=_('Price x position (mm)'), required=False)),
('price_y', forms.FloatField(label=_('Price y position (mm)'), required=False)),
('price_s', forms.FloatField(label=_('Price size (mm)'), required=False,
help_text=_('Visible by default, set this to 0 to hide the element.'))),
('event_x', forms.FloatField(label=_('Event name x position (mm)'), required=False)),
('event_y', forms.FloatField(label=_('Event name y position (mm)'), required=False)),
('event_s', forms.FloatField(label=_('Event name size (mm)'), required=False,
help_text=_('Visible by default, set this to 0 to hide the element.'))),
('attendee_x', forms.FloatField(label=_('Attendee name x position (mm)'), required=False)),
('attendee_y', forms.FloatField(label=_('Attendee name y position (mm)'), required=False)),
('attendee_s', forms.FloatField(label=_('Attendee name size (mm)'), required=False,
help_text=_('Invisible by default, set this to a number greater than 0 '
'to show.')))
]
)
def settings_content_render(self, request: HttpRequest) -> str:
"""
When the event's administrator visits the event configuration
page, this method is called. It may return HTML containing additional information
that is displayed below the form fields configured in ``settings_form_fields``.
"""
template = get_template('pretixplugins/ticketoutputpdf/form.html')
return template.render({
'request': request
})
def _legacy_layout(self):
if self.settings.get('background'):
return self._migrate_from_old_settings()
else:
return self._default_layout()
def _default_layout(self):
return [
{"type": "textarea", "left": "17.50", "bottom": "274.60", "fontsize": "16.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "175.00", "content": "event_name",
"text": "Sample event name", "align": "left"},
{"type": "textarea", "left": "17.50", "bottom": "262.90", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "110.00", "content": "itemvar",
"text": "Sample product sample variation", "align": "left"},
{"type": "textarea", "left": "17.50", "bottom": "252.50", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "110.00", "content": "attendee_name",
"text": "John Doe", "align": "left"},
{"type": "textarea", "left": "17.50", "bottom": "242.10", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "110.00", "content": "event_date",
"text": "May 31st, 2017", "align": "left"},
{"type": "textarea", "left": "17.50", "bottom": "234.30", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "110.00", "content": "event_location",
"text": "Random City", "align": "left"},
{"type": "textarea", "left": "17.50", "bottom": "194.50", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "30.00", "content": "order",
"text": "A1B2C", "align": "left"},
{"type": "textarea", "left": "52.50", "bottom": "194.50", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "45.00", "content": "price",
"text": "123.45 EUR", "align": "right"},
{"type": "textarea", "left": "102.50", "bottom": "194.50", "fontsize": "13.0", "color": [0, 0, 0, 1],
"fontfamily": "Open Sans", "bold": False, "italic": False, "width": "90.00", "content": "secret",
"text": "tdmruoekvkpbv1o2mv8xccvqcikvr58u", "align": "left"},
{"type": "barcodearea", "left": "130.40", "bottom": "204.50", "size": "64.00"}
]
def _migrate_from_old_settings(self):
l = []
event_s = self.settings.get('event_s', default=22, as_type=float)
if event_s:
l.append({
'type': 'textarea',
'fontfamily': 'Helvetica',
'left': self.settings.get('event_x', default=15, as_type=float),
'bottom': self.settings.get('event_y', default=235, as_type=float),
'fontsize': event_s,
'color': [0, 0, 0, 1],
'bold': False,
'italic': False,
'width': 150,
'content': 'event_name',
'text': 'Sample event',
'align': 'left'
})
order_s = self.settings.get('order_s', default=17, as_type=float)
if order_s:
l.append({
'type': 'textarea',
'fontfamily': 'Helvetica',
'left': self.settings.get('order_x', default=15, as_type=float),
'bottom': self.settings.get('order_y', default=220, as_type=float),
'fontsize': order_s,
'color': [0, 0, 0, 1],
'bold': False,
'italic': False,
'width': 150,
'content': 'order',
'text': 'AB1C2',
'align': 'left'
})
name_s = self.settings.get('name_s', default=17, as_type=float)
if name_s:
l.append({
'type': 'textarea',
'fontfamily': 'Helvetica',
'left': self.settings.get('name_x', default=15, as_type=float),
'bottom': self.settings.get('name_y', default=210, as_type=float),
'fontsize': name_s,
'color': [0, 0, 0, 1],
'bold': False,
'italic': False,
'width': 150,
'content': 'itemvar',
'text': 'Sample Producs - XS',
'align': 'left'
})
price_s = self.settings.get('price_s', default=17, as_type=float)
if price_s:
l.append({
'type': 'textarea',
'fontfamily': 'Helvetica',
'left': self.settings.get('price_x', default=15, as_type=float),
'bottom': self.settings.get('price_y', default=200, as_type=float),
'fontsize': price_s,
'color': [0, 0, 0, 1],
'bold': False,
'italic': False,
'width': 150,
'content': 'price',
'text': 'EUR 12,34',
'align': 'left'
})
qr_s = self.settings.get('qr_s', default=80, as_type=float)
if qr_s:
l.append({
'type': 'barcodearea',
'left': self.settings.get('qr_x', default=10, as_type=float),
'bottom': self.settings.get('qr_y', default=120, as_type=float),
'size': qr_s,
})
code_s = self.settings.get('code_s', default=11, as_type=float)
if code_s:
l.append({
'type': 'textarea',
'fontfamily': 'Helvetica',
'left': self.settings.get('code_x', default=15, as_type=float),
'bottom': self.settings.get('code_y', default=120, as_type=float),
'fontsize': code_s,
'color': [0, 0, 0, 1],
'bold': False,
'italic': False,
'width': 150,
'content': 'secret',
'text': 'asdsdgjfgbgkjdastjrxfdg',
'align': 'left'
})
attendee_s = self.settings.get('attendee_s', default=0, as_type=float)
if attendee_s:
l.append({
'type': 'textarea',
'fontfamily': 'Helvetica',
'left': self.settings.get('attendee_x', default=15, as_type=float),
'bottom': self.settings.get('attendee_y', default=90, as_type=float),
'fontsize': attendee_s,
'color': [0, 0, 0, 1],
'bold': False,
'italic': False,
'width': 150,
'content': 'attendee_name',
'text': 'John Doe',
'align': 'left'
})
return l
@@ -0,0 +1,13 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pdfoutput/editor/$', views.EditorView.as_view(),
name='editor'),
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pdfoutput/editor/webfonts.css',
views.FontsCSSView.as_view(),
name='css'),
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pdfoutput/editor/(?P<filename>[^/]+).pdf$',
views.PdfView.as_view(), name='pdf'),
]
+154
View File
@@ -0,0 +1,154 @@
import json
import logging
import mimetypes
from datetime import timedelta
from django.core.files import File
from django.core.files.storage import default_storage
from django.http import (
FileResponse, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django.views.generic import TemplateView
from pretix.base.i18n import language
from pretix.base.models import CachedFile
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import ChartContainingView
from pretix.helpers.database import rolledback_transaction
from pretix.plugins.ticketoutputpdf.signals import get_fonts
from .ticketoutput import PdfTicketOutput
logger = logging.getLogger(__name__)
class EditorView(EventPermissionRequiredMixin, ChartContainingView, TemplateView):
template_name = 'pretixplugins/ticketoutputpdf/index.html'
permission = 'can_change_settings'
accepted_formats = (
'application/pdf',
)
maxfilesize = 1024 * 1024 * 10
minfilesize = 10
def process_upload(self):
f = self.request.FILES.get('background')
error = False
if f.size > self.maxfilesize:
error = _('The uploaded PDF file is to large.')
if f.size < self.minfilesize:
error = _('The uploaded PDF file is to small.')
if mimetypes.guess_type(f.name)[0] not in self.accepted_formats:
error = _('Please only upload PDF files.')
# if there was an error, add error message to response_data and return
if error:
return error, None
return None, f
def post(self, request, *args, **kwargs):
if "background" in request.FILES:
error, fileobj = self.process_upload()
if error:
return JsonResponse({
"status": "error",
"error": error
})
c = CachedFile()
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.type = 'application/pdf'
c.file = fileobj
c.save()
c.refresh_from_db()
return JsonResponse({
"status": "ok",
"id": c.id,
"url": reverse('plugins:ticketoutputpdf:pdf', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'filename': str(c.id)
})
})
cf = None
if request.POST.get("background", "").strip():
try:
cf = CachedFile.objects.get(id=request.POST.get("background"))
except CachedFile.DoesNotExist:
pass
if "preview" in request.POST:
with rolledback_transaction(), language(request.event.settings.locale):
item = request.event.items.create(name=_("Sample product"), default_price=42.23)
from pretix.base.models import Order
order = request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu',
expires=now(), code="PREVIEW1234", total=119)
p = order.positions.create(item=item, attendee_name=_("John Doe"), price=item.default_price)
prov = PdfTicketOutput(request.event,
override_layout=json.loads(request.POST.get("data")),
override_background=cf.file if cf else None)
fname, mimet, data = prov.generate(p)
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
resp['Content-Security-Policy'] = "style-src 'unsafe-inline'; script-src 'unsafe-inline'; object-src 'self'"
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
return resp
elif "data" in request.POST:
if cf:
fexisting = request.event.settings.get('ticketoutput_pdf_layout', as_type=File)
if fexisting:
try:
default_storage.delete(fexisting.name)
except OSError: # pragma: no cover
logger.error('Deleting file %s failed.' % fexisting.name)
# Create new file
nonce = get_random_string(length=8)
fname = '%s-%s/%s/%s.%s.%s' % (
'event', 'settings', self.request.event.pk, 'ticketoutput_pdf_layout', nonce, 'pdf'
)
newname = default_storage.save(fname, cf.file)
request.event.settings.set('ticketoutput_pdf_background', 'file://' + newname)
request.event.settings.set('ticketoutput_pdf_layout', request.POST.get("data"))
return JsonResponse({'status': 'ok'})
return HttpResponseBadRequest()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
prov = PdfTicketOutput(self.request.event)
ctx['fonts'] = get_fonts()
ctx['layout'] = json.dumps(
self.request.event.settings.get('ticketoutput_pdf_layout', as_type=list)
or prov._default_layout()
)
return ctx
class FontsCSSView(TemplateView):
content_type = 'text/css'
template_name = 'pretixplugins/ticketoutputpdf/webfonts.css'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['fonts'] = get_fonts()
return ctx
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
resp = FileResponse(cf.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
return resp
+4
View File
@@ -27,6 +27,8 @@ from pretix.presale.views.questions import QuestionsViewMixin
class BaseCheckoutFlowStep:
requires_valid_cart = True
def __init__(self, event):
self.event = event
self.request = None
@@ -135,6 +137,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
template_name = "pretixpresale/event/checkout_addons.html"
task = set_cart_addons
known_errortypes = ['CartError']
requires_valid_cart = False
def is_applicable(self, request):
return get_cart(request).filter(item__addons__isnull=False).exists()
@@ -378,6 +381,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['providers'] = self.provider_forms
ctx['show_fees'] = any(p['fee'] for p in self.provider_forms)
ctx['selected'] = self.request.POST.get('payment', self.request.session.get('payment', ''))
return ctx
+4
View File
@@ -54,6 +54,10 @@ def contextprocessor(request):
ctx['event_logo'] = request.event.settings.get('logo_image', as_type=str, default='')[7:]
ctx['event'] = request.event
if hasattr(request, 'organizer'):
ctx['organizer_logo'] = request.organizer.settings.get('organizer_logo_image', as_type=str, default='')[7:]
ctx['organizer'] = request.organizer
ctx['html_head'] = "".join(_html_head)
ctx['html_foot'] = "".join(_html_foot)
ctx['footer'] = _footer
+69 -7
View File
@@ -3,12 +3,17 @@ from decimal import Decimal
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count, Prefetch, Q
from django.forms.widgets import RadioChoiceInput, RadioFieldRenderer
from django.utils.encoding import force_text
from django.utils.formats import number_format
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import ItemVariation, Question
from pretix.base.models.orders import InvoiceAddress
from pretix.base.templatetags.rich_text import rich_text
class ContactForm(forms.Form):
@@ -146,6 +151,63 @@ class QuestionsForm(forms.Form):
self.fields['question_%s' % q.id] = field
# The following will get totally different once Django 1.11 is integrated
class AddOnVariationSelectInput(RadioChoiceInput):
def __init__(self, name, value, attrs, choice, index):
super().__init__(name, value, attrs, choice, index)
self.description = force_text(choice[2])
def render(self, name=None, value=None, attrs=None):
if self.id_for_label:
label_for = format_html(' for="{}"', self.id_for_label)
else:
label_for = ''
attrs = dict(self.attrs, **attrs) if attrs else self.attrs
if self.description:
return format_html(
'<label{}>{} {}</label> <span class="fa fa-info-circle toggle-variation-description"></span>'
'<div class="variation-description addon-variation-description">{}</div>',
label_for, self.tag(attrs), self.choice_label,
rich_text(str(self.description))
)
else:
return format_html(
'<label{}>{} {}</label>',
label_for, self.tag(attrs), self.choice_label,
)
class AddOnVariationSelectRenderer(RadioFieldRenderer):
choice_input_class = AddOnVariationSelectInput
def render(self):
id_ = self.attrs.get('id')
output = []
for i, choice in enumerate(self.choices):
w = self.choice_input_class(self.name, self.value, self.attrs.copy(), choice, i)
output.append(format_html(self.inner_html, choice_value=force_text(w), sub_widgets=''))
return format_html(
self.outer_html,
id_attr=format_html(' id="{}"', id_) if id_ else '',
content=mark_safe('\n'.join(output)),
)
class AddOnVariationSelect(forms.RadioSelect):
renderer = AddOnVariationSelectRenderer
class AddOnVariationField(forms.ChoiceField):
def valid_value(self, value):
text_value = force_text(value)
for k, v, d in self.choices:
if value == k or text_value == force_text(k):
return True
return False
class AddOnsForm(forms.Form):
"""
This form class is responsible for selecting add-ons to a product in the cart.
@@ -232,18 +294,18 @@ class AddOnsForm(forms.Form):
for i in items:
if i.has_variations:
choices = [('', _('no selection'))]
choices = [('', _('no selection'), '')]
for v in i.available_variations:
cached_availability = v.check_quotas(_cache=quota_cache)
choices.append((v.pk, self._label(event, v, cached_availability)))
choices.append((v.pk, self._label(event, v, cached_availability), v.description))
field = forms.ChoiceField(
field = AddOnVariationField(
choices=choices,
label=i.name,
required=False,
widget=forms.RadioSelect,
help_text=i.description,
initial=current_addons.get(i.pk)
widget=AddOnVariationSelect,
help_text=rich_text(str(i.description)),
initial=current_addons.get(i.pk),
)
else:
cached_availability = i.check_quotas(_cache=quota_cache)
@@ -251,7 +313,7 @@ class AddOnsForm(forms.Form):
label=self._label(event, i, cached_availability),
required=False,
initial=i.pk in current_addons,
help_text=i.description
help_text=rich_text(str(i.description)),
)
self.fields['item_%s' % i.pk] = field
+4 -4
View File
@@ -20,10 +20,10 @@ def regenerate_css(event_id: int):
event = Event.objects.select_related('organizer').get(pk=event_id)
sassdir = os.path.join(settings.STATIC_ROOT, 'pretixpresale/scss')
sassrules = [
'$brand-primary: {};'.format(event.settings.get('primary_color')),
'@import "main.scss";',
]
sassrules = []
if event.settings.get('primary_color'):
sassrules.append('$brand-primary: {};'.format(event.settings.get('primary_color')))
sassrules.append('@import "main.scss";')
css = sass.compile(
string="\n".join(sassrules),
@@ -22,16 +22,18 @@
{% endblock %}
{% block page %}
<div class="page-header">
<h1 class="pull-left">
<div class="pull-left">
{% if event_logo %}
<a href="{% eventurl event "presale:event.index" %}" title="{{ event.name }}">
<img src="{{ event_logo|thumbnail_url:'logo' }}" alt="{{ event.name }}" class="event-logo" />
</a>
{% else %}
<a href="{% eventurl event "presale:event.index" %}">{{ event.name }}</a>
<small>{{ event.get_date_range_display }}</small>
<h1>
<a href="{% eventurl event "presale:event.index" %}">{{ event.name }}</a>
<small>{{ event.get_date_range_display }}</small>
</h1>
{% endif %}
</h1>
</div>
<div class="pull-right loginbox">
{% if request.event.settings.locales|length > 1 %}
<div class="locales">
@@ -14,7 +14,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#cp{{ form.pos.id }}">
<a data-toggle="collapse" href="#cp{{ form.cartpos.pk }}">
<strong>{{ form.item.name }}</strong>
{% if form.variation %}
{{ form.variation }}
@@ -22,7 +22,7 @@
</a>
</h4>
</div>
<div id="cp{{ form.pos.id }}"
<div id="cp{{ form.cartpos.pk }}"
class="panel-collapse collapsed in">
<div class="panel-body">
{% for c in form.categories %}
@@ -35,7 +35,7 @@
{% endblocktrans %}
{% elif c.min_count == 0 %}
{% blocktrans trimmed with max_count=c.max_count %}
You can to choose up to {{ max_count }} options from this category.
You can choose up to {{ max_count }} options from this category.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %}
@@ -13,7 +13,9 @@
<div class="panel-heading">
<h4 class="panel-title">
<label class="radio">
<strong class="pull-right">+ {{ p.fee|floatformat:2 }} {{ event.currency }}</strong>
{% if show_fees %}
<strong class="pull-right">+ {{ p.fee|floatformat:2 }} {{ event.currency }}</strong>
{% endif %}
<input type="radio" name="payment" value="{{ p.provider.identifier }}"
data-parent="#payment_accordion"
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
@@ -1,7 +1,7 @@
{% load i18n %}
{% load eventurl %}
{% for line in cart.positions %}
<div class="row cart-row {% if download %}has-downloads{% endif %}">
<div class="row cart-row {% if download and line.item.admission|default:event.settings.ticket_download_nonadm %}has-downloads{% endif %}">
<div class="product">
{% if line.addon_to %}
<span class="addon-signifier">+</span>
@@ -33,7 +33,7 @@
{% endif %}
</div>
{% if download %}
{% if download and line.item.admission|default:event.settings.ticket_download_nonadm %}
<div class="download-desktop">
{% if not line.addon_to or event.settings.ticket_download_addons %}
{% for b in download_buttons %}
@@ -8,7 +8,7 @@
{% block title %}{% trans "Presale" %}{% endblock %}
{% block content %}
{% if cart.positions %}
{% if cart.positions and event.presale_is_running %}
<div class="panel panel-primary cart">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Your cart" %}</h3>
@@ -28,13 +28,19 @@
<div class="col-md-4 col-xs-12">
<form method="post" data-asynctask action="{% eventurl request.event "presale:event.cart.clear" %}">
{% csrf_token %}
<button class="btn btn-block btn-default btn-lg" type="submit"><i class="fa fa-close"></i> {% trans "Empty cart" %}</button>
<button class="btn btn-block btn-default btn-lg" type="submit">
<i class="fa fa-close"></i> {% trans "Empty cart" %}</button>
</form>
</div>
<div class="col-md-4 col-md-offset-4 col-xs-12">
<a class="btn btn-block btn-primary btn-lg"
href="{% eventurl request.event "presale:event.checkout.start" %}">
<i class="fa fa-shopping-cart"></i> {% trans "Proceed with checkout" %}
{% if has_addon_choices %}
<i class="fa fa-shopping-cart"></i> {% trans "Continue" %}
</a>
{% else %}
<i class="fa fa-shopping-cart"></i> {% trans "Proceed with checkout" %}
{% endif %}
</a>
</div>
<div class="clearfix"></div>
@@ -61,15 +67,57 @@
{% endif %}
<div>
{% if frontpage_text %}
<div>
{{ frontpage_text|rich_text }}
</div>
<div>
{{ frontpage_text|rich_text }}
</div>
{% endif %}
<p class="text-right">
<a href="{% eventurl event "presale:event.ical.download" %}" class="btn btn-link btn-xs">
<i class="fa fa-calendar"></i> {% trans "Add to Calendar" %}
</a>
</p>
{% if event.location %}
<div class="info-row">
<span class="fa fa-map-marker fa-fw"></span>
<p>
{{ event.location|linebreaksbr }}
</p>
</div>
{% endif %}
{% if event.settings.show_times or event.date_admission %}
<div class="info-row">
<span class="fa fa-clock-o fa-fw"></span>
<p>
{% if event.settings.show_times %}
{% blocktrans trimmed with time=event.date_from|date:"TIME_FORMAT" %}
Begin: {{ time }}
{% endblocktrans %}
{% endif %}
{% if event.date_admission and event.settings.show_times %}
<br>
{% endif %}
{% if event.date_admission %}
{% if event.date_admission|date:"SHORT_DATE_FORMAT" == event.date_from|date:"SHORT_DATE_FORMAT" %}
{% blocktrans trimmed with time=event.date_admission|date:"TIME_FORMAT" %}
Admission: {{ time }}
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with datetime=event.date_admission|date:"SHORT_DATETIME_FORMAT" %}
Admission: {{ datetime }}
{% endblocktrans %}
{% endif %}
{% endif %}
<br>
<a href="{% eventurl event "presale:event.ical.download" %}">
{% trans "Add to Calendar" %}
</a>
</p>
</div>
{% else %}
<p class="text-right">
<a href="{% eventurl event "presale:event.ical.download" %}" class="btn-xs">
<i class="fa fa-calendar"></i> {% trans "Add to Calendar" %}
</a>
</p>
{% endif %}
</div>
{% eventsignal event "pretix.presale.signals.front_page_top" %}
@@ -101,14 +149,19 @@
<a href="#" data-toggle="variations">
<strong>{{ item.name }}</strong>
</a>
{% if item.description %}<p>{{ item.description|localize|rich_text }}</p>
{% if item.description %}
<div class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
{% if item.min_per_order %}
<p><small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small></p>
<p>
<small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small>
</p>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
@@ -134,6 +187,11 @@
<div class="row-fluid product-row variation">
<div class="col-md-8 col-xs-12">
{{ var }}
{% if var.description %}
<div class="variation-description">
{{ var.description|localize|rich_text }}
</div>
{% endif %}
{% if event.settings.show_quota_left %}
{% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %}
{% endif %}
@@ -201,16 +259,21 @@
{% endif %}
<strong>{{ item.name }}</strong>
{% if item.description %}
<p class="description">{{ item.description|localize|rich_text }}</p>{% endif %}
<div class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
{% if event.settings.show_quota_left %}
{% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %}
{% endif %}
{% if item.min_per_order %}
<p><small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small></p>
<p>
<small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small>
</p>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
@@ -37,7 +37,10 @@
{% endif %}
<strong>{{ item.name }}</strong>
{% if item.description %}
<p>{{ item.description|localize|rich_text }}</p>{% endif %}
<div class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% if item.min_price != item.max_price or item.free_price %}
@@ -57,6 +60,11 @@
<div class="row-fluid product-row variation">
<div class="col-md-8 col-xs-12">
{{ var }}
{% if var.description %}
<div class="variation-description">
{{ var.description|localize|rich_text }}
</div>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% if item.free_price %}
@@ -116,7 +124,10 @@
{% endif %}
<strong>{{ item.name }}</strong>
{% if item.description %}
<p class="description">{{ item.description|localize|rich_text }}</p>{% endif %}
<div class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% if item.free_price %}

Some files were not shown because too many files have changed in this diff Show More