2018-07-03 06:22:58 +00:00
|
|
|
|
# Calendar.social
|
|
|
|
|
# Copyright (C) 2018 Gergely Polonkai
|
|
|
|
|
#
|
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
|
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
|
|
|
# (at your option) any later version.
|
|
|
|
|
#
|
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
# GNU Affero General Public License for more details.
|
|
|
|
|
#
|
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
|
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
|
|
"""Forms for Calendar.social
|
|
|
|
|
"""
|
|
|
|
|
|
2018-07-16 11:33:00 +00:00
|
|
|
|
from enum import Enum
|
|
|
|
|
|
2018-07-02 12:33:33 +00:00
|
|
|
|
from flask_babelex import lazy_gettext as _
|
2018-07-08 20:49:10 +00:00
|
|
|
|
from flask_security.forms import LoginForm as BaseLoginForm
|
2018-06-29 12:00:45 +00:00
|
|
|
|
from flask_wtf import FlaskForm
|
2018-07-02 13:07:53 +00:00
|
|
|
|
import pytz
|
2018-07-25 06:35:44 +00:00
|
|
|
|
from wtforms import BooleanField, PasswordField, SelectField, StringField, RadioField
|
2018-06-30 04:44:45 +00:00
|
|
|
|
from wtforms.ext.dateutil.fields import DateTimeField
|
2018-07-10 13:33:57 +00:00
|
|
|
|
from wtforms.validators import DataRequired, Email, StopValidation, ValidationError
|
2018-06-30 04:44:45 +00:00
|
|
|
|
from wtforms.widgets import TextArea
|
2018-06-29 12:00:45 +00:00
|
|
|
|
|
2018-07-16 10:12:35 +00:00
|
|
|
|
from calsocial.models import EventVisibility, EVENT_VISIBILITY_TRANSLATIONS
|
|
|
|
|
|
2018-06-29 12:00:45 +00:00
|
|
|
|
|
2018-07-25 19:19:27 +00:00
|
|
|
|
class UsernameAvailable: # pylint: disable=too-few-public-methods
|
2018-07-10 13:33:57 +00:00
|
|
|
|
"""Checks if a username is available
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, message=None):
|
|
|
|
|
self.message = message
|
|
|
|
|
|
|
|
|
|
def __call__(self, form, field):
|
|
|
|
|
from sqlalchemy.orm.exc import NoResultFound
|
|
|
|
|
|
|
|
|
|
from calsocial.models import User
|
|
|
|
|
|
|
|
|
|
# If there is no data, we don’t raise an error; it’s not the task of this validator to
|
|
|
|
|
# check the validity of the username
|
|
|
|
|
if not field.data:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
User.query.filter(User.username == field.data).one()
|
|
|
|
|
except NoResultFound:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.message is None:
|
|
|
|
|
message = field.gettext('This username is not available')
|
|
|
|
|
else:
|
|
|
|
|
message = self.message
|
|
|
|
|
|
|
|
|
|
field.errors[:] = []
|
|
|
|
|
raise StopValidation(message)
|
|
|
|
|
|
|
|
|
|
|
2018-07-25 19:19:27 +00:00
|
|
|
|
class EmailAvailable: # pylint: disable=too-few-public-methods
|
2018-07-10 13:33:57 +00:00
|
|
|
|
"""Checks if an email address is available
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, message=None):
|
|
|
|
|
self.message = message
|
|
|
|
|
|
|
|
|
|
def __call__(self, form, field):
|
|
|
|
|
from sqlalchemy.orm.exc import NoResultFound
|
|
|
|
|
|
|
|
|
|
from calsocial.models import User
|
|
|
|
|
|
|
|
|
|
# If there is no data, we don’t raise an error; it’s not the task of this validator to
|
|
|
|
|
# check the validity of the username
|
|
|
|
|
if not field.data:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
User.query.filter(User.email == field.data).one()
|
|
|
|
|
except NoResultFound:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.message is None:
|
|
|
|
|
message = field.gettext('This email address can not be used')
|
|
|
|
|
else:
|
|
|
|
|
message = self.message
|
|
|
|
|
|
|
|
|
|
field.errors[:] = []
|
|
|
|
|
raise StopValidation(message)
|
|
|
|
|
|
|
|
|
|
|
2018-06-29 12:00:45 +00:00
|
|
|
|
class RegistrationForm(FlaskForm):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Registration form
|
|
|
|
|
"""
|
|
|
|
|
|
2018-07-10 13:33:57 +00:00
|
|
|
|
username = StringField(_('Username'), validators=[DataRequired(), UsernameAvailable()])
|
|
|
|
|
email = StringField(_('Email address'), validators=[Email(), EmailAvailable()])
|
2018-06-29 12:00:45 +00:00
|
|
|
|
password = PasswordField(_('Password'), validators=[DataRequired()])
|
|
|
|
|
password_retype = PasswordField(_('Password, once more'), validators=[DataRequired()])
|
|
|
|
|
|
|
|
|
|
def validate_password_retype(self, field):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Validate the ``password_retype`` field
|
|
|
|
|
|
|
|
|
|
Its value must match the ``password`` field’s.
|
|
|
|
|
"""
|
|
|
|
|
|
2018-06-29 12:00:45 +00:00
|
|
|
|
if field.data != self.password.data:
|
|
|
|
|
raise ValidationError(_('The two passwords must match!'))
|
2018-06-30 04:44:45 +00:00
|
|
|
|
|
|
|
|
|
|
2018-07-02 13:07:53 +00:00
|
|
|
|
class TimezoneField(SelectField):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Field for selecting a time zone
|
|
|
|
|
|
|
|
|
|
Note: this field overrides whatever is passed to the ``choices`` parameter, and fills
|
|
|
|
|
``choices`` with the common timezones of pytz. In every other aspects, it behaves exactly
|
|
|
|
|
like `SelectField`.
|
|
|
|
|
"""
|
|
|
|
|
|
2018-07-02 13:07:53 +00:00
|
|
|
|
def __init__(self, *args, **kwargs):
|
2018-07-03 06:33:49 +00:00
|
|
|
|
self.data = None
|
|
|
|
|
|
2018-07-02 13:07:53 +00:00
|
|
|
|
kwargs.update({
|
|
|
|
|
'choices': [
|
|
|
|
|
(pytz.timezone(tz), tz.replace('_', ' '))
|
|
|
|
|
for tz in pytz.common_timezones
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
SelectField.__init__(self, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
def process_formdata(self, valuelist):
|
|
|
|
|
if not valuelist:
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.data = pytz.timezone(valuelist[0])
|
|
|
|
|
except pytz.exceptions.UnknownTimeZoneError:
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
raise ValueError('Unknown time zone')
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def is_pytz_instance(value):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Check if ``value`` is a valid pytz time zone instance
|
|
|
|
|
"""
|
|
|
|
|
|
2018-07-02 13:07:53 +00:00
|
|
|
|
return value is pytz.UTC or isinstance(value, pytz.tzinfo.BaseTzInfo)
|
|
|
|
|
|
|
|
|
|
def process_data(self, value):
|
|
|
|
|
if value is None:
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
2018-07-03 06:29:57 +00:00
|
|
|
|
if self.is_pytz_instance(value):
|
2018-07-02 13:07:53 +00:00
|
|
|
|
self.data = value
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.data = pytz.timezone(value)
|
2018-07-03 06:31:21 +00:00
|
|
|
|
except pytz.exceptions.UnknownTimeZoneError:
|
2018-07-02 13:07:53 +00:00
|
|
|
|
raise ValueError(f'Unknown time zone {value}')
|
|
|
|
|
|
|
|
|
|
def iter_choices(self):
|
|
|
|
|
for value, label in self.choices:
|
|
|
|
|
yield (value, label, value == self.data)
|
|
|
|
|
|
|
|
|
|
|
2018-07-16 10:12:35 +00:00
|
|
|
|
class EnumField(SelectField):
|
2018-07-16 11:33:25 +00:00
|
|
|
|
"""Field that allows selecting one value from an ``Enum`` class
|
|
|
|
|
|
|
|
|
|
:param enum_type: an ``Enum`` type
|
|
|
|
|
:type enum_type: type(Enum)
|
|
|
|
|
:param translations: translatable labels for enum values
|
|
|
|
|
:type translations: dict
|
|
|
|
|
:param args: passed verbatim to the constructor of `SelectField`
|
|
|
|
|
:param kwargs: passed verbatim to the constructor of `SelectField`
|
|
|
|
|
"""
|
|
|
|
|
|
2018-07-16 10:12:35 +00:00
|
|
|
|
def __init__(self, enum_type, translations, *args, **kwargs):
|
2018-07-16 11:33:00 +00:00
|
|
|
|
if not issubclass(enum_type, Enum):
|
|
|
|
|
raise TypeError('enum_type must be a subclass of Enum')
|
|
|
|
|
|
2018-07-16 10:12:35 +00:00
|
|
|
|
kwargs.update({'choices': [(value, None) for value in enum_type]})
|
|
|
|
|
self.data = None
|
|
|
|
|
self.enum_type = enum_type
|
|
|
|
|
self.translations = translations
|
|
|
|
|
SelectField.__init__(self, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
def process_formdata(self, valuelist):
|
|
|
|
|
if not valuelist:
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.data = self.enum_type[valuelist[0]]
|
|
|
|
|
except KeyError:
|
|
|
|
|
raise ValueError('Unknown value')
|
|
|
|
|
|
|
|
|
|
def iter_choices(self):
|
|
|
|
|
for value in self.enum_type:
|
2018-07-16 11:32:22 +00:00
|
|
|
|
label = self.gettext(self.translations[value]) if self.translations else value.name
|
2018-07-16 10:12:35 +00:00
|
|
|
|
|
2018-07-16 11:32:22 +00:00
|
|
|
|
yield (value.name, label, value == self.data)
|
2018-07-16 10:12:35 +00:00
|
|
|
|
|
|
|
|
|
|
2018-06-30 04:44:45 +00:00
|
|
|
|
class EventForm(FlaskForm):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Form for event creation/editing
|
|
|
|
|
"""
|
|
|
|
|
|
2018-06-30 04:44:45 +00:00
|
|
|
|
title = StringField(_('Title'), validators=[DataRequired()])
|
2018-07-02 13:07:53 +00:00
|
|
|
|
time_zone = TimezoneField(_('Time zone'), validators=[DataRequired()])
|
2018-06-30 04:44:45 +00:00
|
|
|
|
start_time = DateTimeField(_('Start time'), validators=[DataRequired()])
|
|
|
|
|
end_time = DateTimeField(_('End time'), validators=[DataRequired()])
|
|
|
|
|
all_day = BooleanField(_('All day'))
|
|
|
|
|
description = StringField(_('Description'), widget=TextArea())
|
2018-07-16 10:12:35 +00:00
|
|
|
|
visibility = EnumField(EventVisibility, EVENT_VISIBILITY_TRANSLATIONS, label=_('Visibility'))
|
2018-06-30 04:44:45 +00:00
|
|
|
|
|
2018-07-16 10:37:32 +00:00
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
from flask_security import current_user
|
|
|
|
|
|
2018-07-16 11:33:21 +00:00
|
|
|
|
self.time_zone.kwargs['default'] = current_user.timezone # pylint: disable=no-member
|
2018-07-16 10:37:32 +00:00
|
|
|
|
|
|
|
|
|
FlaskForm.__init__(self, *args, **kwargs)
|
|
|
|
|
|
2018-07-02 13:07:53 +00:00
|
|
|
|
def populate_obj(self, obj):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Populate ``obj`` with event data
|
|
|
|
|
"""
|
|
|
|
|
|
2018-07-02 13:07:53 +00:00
|
|
|
|
FlaskForm.populate_obj(self, obj)
|
|
|
|
|
|
2018-07-03 06:27:17 +00:00
|
|
|
|
timezone = self.time_zone.data
|
2018-07-02 13:07:53 +00:00
|
|
|
|
|
2018-07-03 06:27:17 +00:00
|
|
|
|
obj.time_zone = str(timezone)
|
|
|
|
|
obj.start_time = timezone.localize(self.start_time.data).astimezone(pytz.utc)
|
|
|
|
|
obj.end_time = timezone.localize(self.end_time.data).astimezone(pytz.utc)
|
2018-07-02 13:07:53 +00:00
|
|
|
|
|
2018-06-30 04:44:45 +00:00
|
|
|
|
def validate_end_time(self, field):
|
2018-07-03 06:22:58 +00:00
|
|
|
|
"""Validate the ``end_time`` field
|
|
|
|
|
|
|
|
|
|
Its value must be later than the value of the ``start_time`` field.
|
|
|
|
|
"""
|
|
|
|
|
|
2018-06-30 04:44:45 +00:00
|
|
|
|
if field.data < self.start_time.data:
|
|
|
|
|
raise ValidationError(_('End time must be later than start time!'))
|
2018-07-03 11:15:17 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsForm(FlaskForm):
|
|
|
|
|
"""Form for user settings
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
timezone = TimezoneField(_('Time zone'), validators=[DataRequired()])
|
|
|
|
|
|
|
|
|
|
def __init__(self, user, *args, **kwargs):
|
|
|
|
|
self.user = user
|
|
|
|
|
|
|
|
|
|
kwargs.setdefault('timezone', user.timezone)
|
|
|
|
|
|
|
|
|
|
FlaskForm.__init__(self, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
def populate_obj(self, user):
|
|
|
|
|
"""Populate ``obj`` with event data
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
for name, field in self._fields.items():
|
|
|
|
|
if not (hasattr(self.__class__, name) and not hasattr(FlaskForm, name)):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
user.settings[name] = str(field.data)
|
2018-07-08 20:49:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LoginForm(BaseLoginForm):
|
|
|
|
|
"""Login form for Calendar.social
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
email = StringField(_('Username or email'), validators=[DataRequired()])
|
|
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
super(LoginForm, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
self.user = None
|
|
|
|
|
|
|
|
|
|
def validate(self):
|
|
|
|
|
from flask_security.utils import _datastore
|
|
|
|
|
from flask_security.utils import verify_and_update_password
|
|
|
|
|
|
|
|
|
|
from .models import AuditLog
|
|
|
|
|
|
|
|
|
|
ret = super(LoginForm, self).validate()
|
|
|
|
|
|
|
|
|
|
if self.user is None:
|
|
|
|
|
self.user = _datastore.get_user(self.email.data)
|
|
|
|
|
|
|
|
|
|
if self.user is None:
|
|
|
|
|
# We can’t figure out the user that’s trying to log in, just return
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
if not verify_and_update_password(self.password.data, self.user):
|
|
|
|
|
AuditLog.log(self.user, AuditLog.TYPE_LOGIN_FAIL)
|
|
|
|
|
|
|
|
|
|
return ret
|
2018-07-09 15:16:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfileField(StringField):
|
|
|
|
|
"""Input field for profiles
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def process_formdata(self, valuelist):
|
|
|
|
|
from sqlalchemy.orm.exc import NoResultFound
|
|
|
|
|
|
|
|
|
|
from .models import User, Profile
|
|
|
|
|
|
|
|
|
|
if not valuelist:
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
value = valuelist[0]
|
|
|
|
|
|
|
|
|
|
if value.startswith('@'):
|
|
|
|
|
value = value[1:]
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.data = Profile.query.join(User).filter(User.username == value).one()
|
|
|
|
|
except NoResultFound:
|
|
|
|
|
self.data = None
|
|
|
|
|
|
|
|
|
|
raise ValueError('Unknown user')
|
|
|
|
|
|
|
|
|
|
def _value(self):
|
|
|
|
|
if self.data:
|
|
|
|
|
return self.data.fqn
|
|
|
|
|
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InviteForm(FlaskForm):
|
|
|
|
|
"""Form for event invitations
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
invitee = ProfileField(validators=[DataRequired()])
|
|
|
|
|
|
|
|
|
|
def __init__(self, event, *args, **kwargs):
|
|
|
|
|
FlaskForm.__init__(self, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
self.event = event
|
|
|
|
|
|
|
|
|
|
def validate_invitee(self, field):
|
|
|
|
|
"""Validate the value of the invitee field
|
|
|
|
|
|
|
|
|
|
:raises ValidationError: If the given user is already invited
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.orm.exc import NoResultFound
|
|
|
|
|
|
|
|
|
|
from .models import Invitation
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
Invitation.query \
|
|
|
|
|
.filter(Invitation.event == self.event) \
|
|
|
|
|
.filter(Invitation.invitee == field.data) \
|
|
|
|
|
.one()
|
|
|
|
|
|
|
|
|
|
raise ValidationError(_('User is already invited'))
|
|
|
|
|
except NoResultFound:
|
|
|
|
|
pass
|
2018-07-10 14:38:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FirstStepsForm(FlaskForm):
|
|
|
|
|
"""Form for the initial profile setup
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
display_name = StringField(
|
|
|
|
|
label=_('Display name'),
|
|
|
|
|
validators=[DataRequired()],
|
2018-07-12 08:11:13 +00:00
|
|
|
|
# pylint: disable=line-too-long
|
2018-07-10 14:38:53 +00:00
|
|
|
|
description=_('This will be shown to other users as your name. You can use your real name, or any nickname you like.'))
|
|
|
|
|
time_zone = TimezoneField(
|
|
|
|
|
label=_('Your time zone'),
|
|
|
|
|
validators=[DataRequired()],
|
|
|
|
|
description=_('The start and end times of events will be displayed in this time zone.'))
|
2018-07-11 07:37:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfileForm(FlaskForm):
|
|
|
|
|
"""Form for editing a user profile
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
display_name = StringField(label=_('Display name'), validators=[DataRequired()])
|
2018-07-25 06:35:44 +00:00
|
|
|
|
builtin_avatar = RadioField(label=_('Use a built-in avatar'))
|
2018-07-12 10:17:18 +00:00
|
|
|
|
locked = BooleanField(label=_('Lock profile'))
|
2018-07-11 07:37:26 +00:00
|
|
|
|
|
|
|
|
|
def __init__(self, profile, *args, **kwargs):
|
2018-07-25 06:35:44 +00:00
|
|
|
|
from flask import current_app
|
|
|
|
|
|
|
|
|
|
kwargs.update(
|
|
|
|
|
{
|
|
|
|
|
'display_name': profile.display_name,
|
|
|
|
|
'locked': profile.locked,
|
|
|
|
|
'builtin_avatar': profile.builtin_avatar,
|
|
|
|
|
})
|
2018-07-11 07:37:26 +00:00
|
|
|
|
FlaskForm.__init__(self, *args, **kwargs)
|
|
|
|
|
|
2018-07-25 19:19:27 +00:00
|
|
|
|
self.builtin_avatar.choices = [(name, name)
|
|
|
|
|
for name in current_app.config['BUILTIN_AVATARS']]
|
2018-07-11 07:37:26 +00:00
|
|
|
|
self.profile = profile
|