From: Michael Tremer Date: Tue, 16 Oct 2018 23:36:43 +0000 (+0100) Subject: people: Add page to edit user accounts X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=e96e445be8ec96515dea306d12d4a065b5399d45;p=ipfire.org.git people: Add page to edit user accounts Signed-off-by: Michael Tremer --- diff --git a/Makefile.am b/Makefile.am index 739ca836..679d340f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -158,6 +158,7 @@ templates_people_DATA = \ src/templates/people/registrations.html \ src/templates/people/search.html \ src/templates/people/user.html \ + src/templates/people/user-edit.html \ src/templates/people/users.html templates_peopledir = $(templatesdir)/people diff --git a/src/backend/accounts.py b/src/backend/accounts.py index 6547ec4b..aa8b5024 100644 --- a/src/backend/accounts.py +++ b/src/backend/accounts.py @@ -4,10 +4,13 @@ import PIL import io import ldap +import ldap.modlist import logging +import phonenumbers import urllib.parse import urllib.request +from . import util from .decorators import * from .misc import Object @@ -152,7 +155,7 @@ class Account(Object): Object.__init__(self, backend) self.dn = dn - self.__attrs = attrs or {} + self.attributes = attrs or {} def __str__(self): return self.name @@ -172,28 +175,74 @@ class Account(Object): def ldap(self): return self.accounts.ldap - @property - def attributes(self): - return self.__attrs + def _exists(self, key): + try: + self.attributes[key] + except KeyError: + return False - def _get_first_attribute(self, attr, default=None): - if attr not in self.attributes: - return default + return True - res = self.attributes.get(attr, []) - if res: - return res[0] + def _get(self, key): + for value in self.attributes.get(key, []): + yield value - def get(self, key): - try: - attribute = self.attributes[key] - except KeyError: - raise AttributeError(key) + def _get_bytes(self, key, default=None): + for value in self._get(key): + return value + + return default + + def _get_strings(self, key): + for value in self._get(key): + yield value.decode() + + def _get_string(self, key, default=None): + for value in self._get_strings(key): + return value + + return default + + def _get_phone_numbers(self, key): + for value in self._get_strings(key): + yield phonenumbers.parse(value, None) + + def _modify(self, modlist): + logging.debug("Modifying %s: %s" % (self.dn, modlist)) + + # Run modify operation + self.ldap.modify_s(self.dn, modlist) + + def _set(self, key, values): + current = self._get(key) + + # Don't do anything if nothing has changed + if list(current) == values: + return + + # Remove all old values and add all new ones + modlist = [] - if len(attribute) == 1: - return attribute[0] + if self._exists(key): + modlist.append((ldap.MOD_DELETE, key, None)) - return attribute + # Add new values + modlist.append((ldap.MOD_ADD, key, values)) + + # Run modify operation + self._modify(modlist) + + # Update cache + self.attributes.update({ key : values }) + + def _set_bytes(self, key, values): + return self._set(key, values) + + def _set_strings(self, key, values): + return self._set(key, [e.encode() for e in values]) + + def _set_string(self, key, value): + return self._set_strings(key, [value,]) def check_password(self, password): """ @@ -220,21 +269,54 @@ class Account(Object): return "sipUser" in self.classes or "sipRoutingObject" in self.classes \ or self.telephone_numbers or self.address + def can_be_managed_by(self, account): + """ + Returns True if account is allowed to manage this account + """ + # Admins can manage all accounts + if account.is_admin(): + return True + + # Users can manage themselves + return self == account + @property def classes(self): - return (x.decode() for x in self.attributes.get("objectClass", [])) + return self._get_strings("objectClass") @property def uid(self): - return self._get_first_attribute("uid").decode() + return self._get_string("uid") @property def name(self): - return self._get_first_attribute("cn").decode() + return self._get_string("cn") - @property - def first_name(self): - return self._get_first_attribute("givenName").decode() + # First Name + + def get_first_name(self): + return self._get_string("givenName") + + def set_first_name(self, first_name): + self._set_string("givenName", first_name) + + # Update Common Name + self._set_string("cn", "%s %s" % (first_name, self.last_name)) + + first_name = property(get_first_name, set_first_name) + + # Last Name + + def get_last_name(self): + return self._get_string("sn") + + def set_last_name(self, last_name): + self._set_string("sn", last_name) + + # Update Common Name + self._set_string("cn", "%s %s" % (self.first_name, last_name)) + + last_name = property(get_last_name, set_last_name) @lazy_property def groups(self): @@ -250,12 +332,22 @@ class Account(Object): return groups - @property - def address(self): - address = self._get_first_attribute("homePostalAddress", "".encode()).decode() - address = address.replace(", ", "\n") + # Address + + def get_address(self): + address = self._get_string("homePostalAddress") + + if address: + return (line.strip() for line in address.split(",")) - return address + return [] + + def set_address(self, address): + data = ", ".join(address.splitlines()) + + self._set_bytes("homePostalAddress", data.encode()) + + address = property(get_address, set_address) @property def email(self): @@ -275,32 +367,84 @@ class Account(Object): # If everything else fails, we will go with the UID return "%s@ipfire.org" % self.uid + # Mail Routing Address + + def get_mail_routing_address(self): + return self._get_string("mailRoutingAddress", None) + + def set_mail_routing_address(self, address): + self._set_string("mailRoutingAddress", address) + + mail_routing_address = property(get_mail_routing_address, set_mail_routing_address) + @property def sip_id(self): if "sipUser" in self.classes: - return self._get_first_attribute("sipAuthenticationUser").decode() + return self._get_string("sipAuthenticationUser") if "sipRoutingObject" in self.classes: - return self._get_first_attribute("sipLocalAddress").decode() + return self._get_string("sipLocalAddress") @property def sip_password(self): - return self._get_first_attribute("sipPassword").decode() + return self._get_string("sipPassword") + + @staticmethod + def _generate_sip_password(): + return util.random_string(8) @property def sip_url(self): return "%s@ipfire.org" % self.sip_id def uses_sip_forwarding(self): - if self.sip_routing_url: + if self.sip_routing_address: return True return False - @property - def sip_routing_url(self): + # SIP Routing + + def get_sip_routing_address(self): if "sipRoutingObject" in self.classes: - return self._get_first_attribute("sipRoutingAddress").decode() + return self._get_string("sipRoutingAddress") + + def set_sip_routing_address(self, address): + if not address: + address = None + + # Don't do anything if nothing has changed + if self.get_sip_routing_address() == address: + return + + if address: + modlist = [ + # This is no longer a SIP user any more + (ldap.MOD_DELETE, "objectClass", b"sipUser"), + (ldap.MOD_DELETE, "sipAuthenticationUser", None), + (ldap.MOD_DELETE, "sipPassword", None), + + (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"), + (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()), + (ldap.MOD_ADD, "sipRoutingAddress", address.encode()), + ] + else: + modlist = [ + (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"), + (ldap.MOD_DELETE, "sipLocalAddress", None), + (ldap.MOD_DELETE, "sipRoutingAddress", None), + + (ldap.MOD_ADD, "objectClass", b"sipUser"), + (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()), + (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()), + ] + + # Run modification + self._modify(modlist) + + # XXX Cache is invalid here + + sip_routing_address = property(get_sip_routing_address, set_sip_routing_address) @lazy_property def sip_registrations(self): @@ -316,26 +460,46 @@ class Account(Object): def get_cdr(self, date=None, limit=None): return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit) - @property - def telephone_numbers(self): - return self._telephone_numbers + self.mobile_telephone_numbers \ - + self.home_telephone_numbers + # Phone Numbers - @property - def _telephone_numbers(self): - return list((x.decode() for x in self.attributes.get("telephoneNumber") or [])) + def get_phone_numbers(self): + ret = [] - @property - def home_telephone_numbers(self): - return list((x.decode() for x in self.attributes.get("homePhone") or [])) + for field in ("telephoneNumber", "homePhone", "mobile"): + for number in self._get_phone_numbers(field): + ret.append(number) - @property - def mobile_telephone_numbers(self): - return list((x.decode() for x in self.attributes.get("mobile") or [])) + return ret + + def set_phone_numbers(self, phone_numbers): + # Sort phone numbers by landline and mobile + _landline_numbers = [] + _mobile_numbers = [] + + for number in phone_numbers: + try: + number = phonenumbers.parse(number, None) + except phonenumbers.phonenumberutil.NumberParseException: + continue + + # Convert to string (in E.164 format) + s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) + + # Separate mobile numbers + if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE: + _mobile_numbers.append(s) + else: + _landline_numbers.append(s) + + # Save + self._set_strings("telephoneNumber", _landline_numbers) + self._set_strings("mobile", _mobile_numbers) + + phone_numbers = property(get_phone_numbers, set_phone_numbers) @property def _all_telephone_numbers(self): - return [ self.sip_id, ] + self.telephone_numbers + return [ self.sip_id, ] + list(self.phone_numbers) def avatar_url(self, size=None): if self.backend.debug: @@ -351,7 +515,7 @@ class Account(Object): return url def get_avatar(self, size=None): - avatar = self._get_first_attribute("jpegPhoto") + avatar = self._get_bytes("jpegPhoto") if not avatar: return diff --git a/src/backend/util.py b/src/backend/util.py index bcd8ca56..e64039ee 100644 --- a/src/backend/util.py +++ b/src/backend/util.py @@ -1,5 +1,8 @@ #!/usr/bin/python +import random +import string + def format_size(s): units = ("B", "k", "M", "G", "T") @@ -24,3 +27,10 @@ def format_time(s, shorter=True): return _("%(min)d min") % { "min" : min } return _("%(hrs)d:%(min)02d hrs") % {"hrs" : hrs, "min" : min} + +def random_string(length=8): + input_chars = string.ascii_letters + string.digits + + r = (random.choice(input_chars) for i in range(length)) + + return "".join(r) diff --git a/src/templates/people/user-edit.html b/src/templates/people/user-edit.html new file mode 100644 index 00000000..24b4521e --- /dev/null +++ b/src/templates/people/user-edit.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% block title %}{{ _("Edit %s") % account }}{% end block %} + +{% block main %} +
+
+

{{ _("Edit %s") % account }}

+ +
+ {% raw xsrf_form_html() %} + +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + + + + {{ _("Enter your full address including your country") }} + +
+ +
+ {{ _("Email") }} + +
+ + + + + + {{ _("All emails will be forwarded to this email address") }} + +
+
+ +
+ {{ _("Telephone") }} + +
+ + + + + + {{ _("Enter your landline and mobile phone numbers") }} + +
+ +
+ + + + + + {{ _("All calls will be forwarded to this phone number or SIP URI") }} + +
+
+ + +
+
+
+{% end block %} + +{% block right %}{% end block %} diff --git a/src/templates/people/user.html b/src/templates/people/user.html index a7f3df26..b9b8b635 100644 --- a/src/templates/people/user.html +++ b/src/templates/people/user.html @@ -3,6 +3,8 @@ {% block title %}{{ account }}{% end block %} {% block main %} + {% import phonenumbers %} +
{{ account }} @@ -35,28 +37,28 @@
{{ account.name }}
- {% for line in account.address.splitlines() %} + {% for line in account.address %} {{ line }}
{% end %}
{% end %} - {% if account.telephone_numbers %} + {% if account.phone_numbers %}
-
{{ _("Telephone Numbers") }}
+
{{ _("Phone Numbers") }}
    - {% for number in account.telephone_numbers %} + {% for number in account.phone_numbers %}
  • - {% if number in account.mobile_telephone_numbers %} - - {% elif number in account.home_telephone_numbers %} - + {% if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE %} + {% else %} {% end %} - {{ number }} + + {{ format_phone_number(number) }}
  • {% end %}
@@ -67,9 +69,17 @@ {% if (current_user == account or current_user.is_admin()) and account.uses_sip_forwarding() %}

{{ _("All calls are forwarded to") }} - {{ account.sip_routing_url }} + {{ account.sip_routing_address }}

{% end %} + +
+ {% if account.can_be_managed_by(current_user) %} + + {{ _("Edit") }} + + {% end %} +
diff --git a/src/web/__init__.py b/src/web/__init__.py index c6af8296..0162d49a 100644 --- a/src/web/__init__.py +++ b/src/web/__init__.py @@ -4,6 +4,7 @@ import logging import itertools import os.path import phonenumbers +import phonenumbers.geocoder import tornado.locale import tornado.options import tornado.web @@ -41,9 +42,11 @@ class Application(tornado.web.Application): # UI Modules "ui_methods" : { - "format_month_name" : self.format_month_name, - "format_phone_number" : self.format_phone_number, - "grouper" : grouper, + "format_month_name" : self.format_month_name, + "format_phone_number" : self.format_phone_number, + "format_phone_number_to_e164" : self.format_phone_number_to_e164, + "format_phone_number_location" : self.format_phone_number_location, + "grouper" : grouper, }, "ui_modules" : { # Blog @@ -252,6 +255,7 @@ class Application(tornado.web.Application): (r"/users/(\w+)", people.UserHandler), (r"/users/(\w+)\.jpg", people.AvatarHandler), (r"/users/(\w+)/calls(?:/(\d{4}-\d{2}-\d{2}))?", people.CallsHandler), + (r"/users/(\w+)/edit", people.UserEditHandler), (r"/users/(\w+)/registrations", people.RegistrationsHandler), ] + authentication_handlers) @@ -292,14 +296,26 @@ class Application(tornado.web.Application): return month - def format_phone_number(self, handler, s): - try: - number = phonenumbers.parse(s, None) - except phonenumbers.phonenumberutil.NumberParseException: - return s + def format_phone_number(self, handler, number): + if not isinstance(number, phonenumbers.PhoneNumber): + try: + number = phonenumbers.parse(s, None) + except phonenumbers.phonenumberutil.NumberParseException: + return number return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.INTERNATIONAL) + def format_phone_number_to_e164(self, handler, number): + return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) + + def format_phone_number_location(self, handler, number): + s = [ + phonenumbers.geocoder.description_for_number(number, handler.locale.code), + phonenumbers.region_code_for_number(number), + ] + + return ", ".join((e for e in s if e)) + def grouper(handler, iterator, n): """ diff --git a/src/web/people.py b/src/web/people.py index 0ad72de6..bcaae6f3 100644 --- a/src/web/people.py +++ b/src/web/people.py @@ -1,6 +1,7 @@ #!/usr/bin/python import datetime +import ldap import logging import tornado.web @@ -120,6 +121,48 @@ class UserHandler(base.BaseHandler): self.render("people/user.html", account=account) +class UserEditHandler(base.BaseHandler): + @tornado.web.authenticated + def get(self, uid): + account = self.backend.accounts.get_by_uid(uid) + if not account: + raise tornado.web.HTTPError(404, "Could not find account %s" % uid) + + # Check for permissions + if not account.can_be_managed_by(self.current_user): + raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) + + self.render("people/user-edit.html", account=account) + + @tornado.web.authenticated + def post(self, uid): + account = self.backend.accounts.get_by_uid(uid) + if not account: + raise tornado.web.HTTPError(404, "Could not find account %s" % uid) + + # Check for permissions + if not account.can_be_managed_by(self.current_user): + raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) + + # Unfortunately this cannot be wrapped into a transaction + try: + account.first_name = self.get_argument("first_name") + account.last_name = self.get_argument("last_name") + account.address = self.get_argument("address") + + # Email + account.mail_routing_address = self.get_argument("mail_routing_address", None) + + # Telephone + account.phone_numbers = self.get_argument("phone_numbers", "").splitlines() + account.sip_routing_address = self.get_argument("sip_routing_address", None) + except ldap.STRONG_AUTH_REQUIRED as e: + raise tornado.web.HTTPError(403, "%s" % e) from e + + # Redirect back to user page + self.redirect("/users/%s" % account.uid) + + class AccountsListModule(ui_modules.UIModule): def render(self, accounts=None): if accounts is None: