diff --git a/web/webapp/directory.py b/web/webapp/directory.py index fb281fd..16a4040 100644 --- a/web/webapp/directory.py +++ b/web/webapp/directory.py @@ -47,6 +47,50 @@ def _setup_ldap(): def _destroy_ldap(exception=None): g.ldap.unbind_s() +def get_ldap_group_diff(members): + active_members = filter(lambda m: m['judgement'], members) + fatty = set([member['username'] for member in active_members if member['type'] in ['fatty', 'supporting']]) + starving = set([member['username'] for member in active_members if member['type'] in ['starving']]) + + ldap_fatty = set(get_group_members(g.ldap, 'fatty')) + ldap_starving = set(get_group_members(g.ldap, 'starving')) + ldap_potato = set(get_group_members(g.ldap, 'potato')) + + result = {} + result['fatty_to_remove'] = list(ldap_fatty - fatty) + result['fatty_to_add'] = list(fatty - ldap_fatty) + result['starving_to_remove'] = list(ldap_starving - starving) + result['starving_to_add'] = list(starving - ldap_starving) + if sum([len(result[k]) for k in result]) == 0: + return None + return result + +def update_member_groups(c, changes): + ops = {'add': ldap.MOD_ADD, 'remove': ldap.MOD_DELETE} + for group in changes: + modlist = [] + for op in changes[group]: + for username in changes[group][op]: + modlist.append((ops[op],'uniqueMember','uid={},{}'.format(username.encode('utf-8'),app.config['LDAP_USER_BASE']))) + c.modify_s('cn={},{}'.format(group.encode('utf-8'),app.config['LDAP_GROUP_BASE']), modlist) + #print group, modlist + +def get_group_members(c, group): + lfilter = '(&(cn={}){})'.format(group, app.config['LDAP_GROUP_FILTER']) + data = c.search_s(app.config['LDAP_GROUP_BASE'], ldap.SCOPE_SUBTREE, + lfilter, tuple(['uniqueMember',])) + + members = [] + for dn, obj in data: + for k, v in obj.iteritems(): + if k == "uniqueMember": + for iv in v: + part,uid,index = ldap.dn.str2dn(iv)[0][0] + if not part == 'uid' or not index == 1: + raise ValueError("First part type {} or index {} seem wrong for DN {}".format(part,index,iv)) + members.append(uid.decode('utf-8')) + return members + def get_member_fields(c, member, fields): if isinstance(fields, str): fields = [fields,] diff --git a/web/webapp/forms.py b/web/webapp/forms.py index ad5e908..97dae07 100644 --- a/web/webapp/forms.py +++ b/web/webapp/forms.py @@ -22,8 +22,11 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from wtforms import Form, TextField, PasswordField, validators +from wtforms import Form, TextField, PasswordField, SelectMultipleField, FormField, validators, widgets +class MultiCheckboxField(SelectMultipleField): + widget = widgets.ListWidget(prefix_label=False) + option_widget = widgets.CheckboxInput() class LoginForm(Form): username = TextField('Username', [validators.Required()]) @@ -33,3 +36,10 @@ class LoginForm(Form): class BREFetchForm(Form): identifier = TextField("Identifier", [validators.Required()]) token = PasswordField("Identifier", [validators.Required()]) + +class LDAPSyncForm(Form): + fatty_to_add = MultiCheckboxField("Fatty to add", choices=[]) + fatty_to_remove = MultiCheckboxField("Fatty to remove", choices=[]) + starving_to_add = MultiCheckboxField("Starving to add", choices=[]) + starving_to_remove = MultiCheckboxField("Starving to remove", choices=[]) + diff --git a/web/webapp/models.py b/web/webapp/models.py index b415401..f816f95 100644 --- a/web/webapp/models.py +++ b/web/webapp/models.py @@ -32,6 +32,7 @@ import json import re from sqlalchemy.orm import subqueryload_all +from sqlalchemy.sql.expression import or_ from flask import g from webapp import app, db, mc, cache_enabled @@ -88,7 +89,7 @@ class Member(db.Model): api_keys = db.relationship("APIKey") join_year = db.Column(db.Integer) join_month = db.Column(db.Integer) - ldap_username = db.Column(db.String(64), unique=True) + alias = db.Column(db.String(64)) # Normal - standard 3 months grace period # Extended Grace Period - do not shut off account after grace period # Potato - do not ever shut off account, report falsified payment status @@ -148,8 +149,8 @@ class Member(db.Model): del now_date status = {} - status['ldap_username'] = self.ldap_username status['username'] = self.username + status['alias'] = self.alias status['type'] = self.type status['payment_policy'] = self.payment_policy # First check - did we actually get any transfers? @@ -236,19 +237,22 @@ class Member(db.Model): def get_list_email(self): if self.preferred_email: return self.preferred_email - return '{}@hackerspace.pl'.format(self.ldap_username) + return '{}@hackerspace.pl'.format(self.username) def get_contact_email(self): if self.preferred_email: return self.preferred_email - mra = directory.get_member_fields(g.ldap, self.ldap_username, + mra = directory.get_member_fields(g.ldap, self.username, 'mailRoutingAddress') mra = mra['mailRoutingAddress'] if mra: return mra else: - return '{}@hackerspace.pl'.format(self.ldap_username) + return '{}@hackerspace.pl'.format(self.username) + def invalidate_cache(self): + cache_key = 'kasownik-payment_status-{}'.format(self.username) + mc.delete(cache_key) def get_status(self): """It's better to call this after doing a full select of data.""" @@ -302,7 +306,6 @@ class Member(db.Model): now_date = datetime.datetime.now() self.join_year = now_date.year self.join_month = now_date.month - self.ldap_username = _username self.payment_policy = PaymentPolicy.normal.value @@ -330,7 +333,7 @@ class Transfer(db.Model): return self.uid[:16] def parse_title(self): - m = re.match(ur"^([a-z0-9\-_\.]+) *\- *(fatty|starving|superfatty|supporting|supporter) *\- *([0-9a-z\-_ąężźćóżłśń \(\),/\.]+$)", self.title.strip().lower()) + m = re.match(ur"^([a-z0-9ąężźćóżłśń\-_\.]+) *\- *(fatty|starving|superfatty|supporting|supporter) *\- *([0-9a-z\-_ąężźćóżłśń \(\),/\.]+$)", self.title.strip().lower()) if not m: return (None, None, None) member, _type, title = m.group(1), m.group(2), m.group(3) @@ -343,16 +346,19 @@ class Transfer(db.Model): title = self.parse_title() if not title[0]: return self.MATCH_UNPARSEABLE, self.title - member_name = title[0] - member = Member.query.filter_by(username=member_name).first() + member = Member.query.filter(or_(Member.username==member_name, Member.alias==member_name)).first() if not member: - return self.MATCH_NO_USER, member_name - - if (title[1] == 'starving' and self.amount > 50) or (title[1] == 'fatty' and self.amount > 100): - return self.MATCH_WRONG_TYPE, member - + return self.MATCH_NO_USER, member_name, 0 + if title[2]: - return self.MATCH_WRONG_TYPE, member + return self.MATCH_WRONG_TYPE, member, 0 - return self.MATCH_OK, member + if title[1] == 'starving' and self.amount >= (50*100) and (self.amount % (50*100)) == 0: + return self.MATCH_OK, member, (self.amount/(50*100)) + + if title[1] == 'fatty' and self.amount >= (100*100) and (self.amount % (100*100)) == 0: + return self.MATCH_OK, member, (self.amount/(100*100)) + + + return self.MATCH_WRONG_TYPE, member, 0 diff --git a/web/webapp/static/css/main.css b/web/webapp/static/css/main.css index e63b2f9..a83174d 100644 --- a/web/webapp/static/css/main.css +++ b/web/webapp/static/css/main.css @@ -1,5 +1,6 @@ body { - padding-top: 50px; + padding-top: 75px; + padding-bottom: 50px; } .logincontainer { padding: 40px 15px; @@ -41,10 +42,10 @@ body { } .flashes { - position: absolute; + /*position: absolute;*/ z-index: 100; - width: 90%; - margin-left: 5%; + width: 70%; + margin-left: 15%; padding-top: 10px; } diff --git a/web/webapp/templates/admin_csv.html b/web/webapp/templates/admin_csv.html index 7ab7acd..d07d8b7 100644 --- a/web/webapp/templates/admin_csv.html +++ b/web/webapp/templates/admin_csv.html @@ -1,79 +1,2 @@ -{% extends "root.html" %} -{% set active_page = "admin" %} -{% block title %}Admin Member List{% endblock %} -{% block content %} - -
-
-
-

Active operations:

-

Available operations:

-

- - Fetch transfer data -

-
- {% for group in active_members|groupby("type") %} -
-

Active members, {{ group.grouper }}:

- - - - - - - - {% for member in group.list %} - - - - - - - {% endfor %} -
#LDAP UsernameMonths DuePayment Policy
{{loop.index}}. - - {{member.ldap_username}} - - - - {{member.months_due}} - - {% include "button_payment_policy.html" %}
-
- {% endfor %} -
-

Inactive members:

- - - - - - - - {% for member in inactive_members %} - - - - - - - {% endfor %} -
#LDAP UsernameMonths DuePayment Policy
{{loop.index}}. - - {{member.ldap_username}} - - - - {{member.months_due}} - - {% include "button_payment_policy.html" %}
-
-
-
-{% endblock %} +
{% for member in active_members %}{{loop.index}},{{member.username}},{{member.cn}}
+{% endfor %}
diff --git a/web/webapp/templates/admin_index.html b/web/webapp/templates/admin_index.html index 7ab7acd..ddfecaf 100644 --- a/web/webapp/templates/admin_index.html +++ b/web/webapp/templates/admin_index.html @@ -16,64 +16,69 @@ --> Fetch transfer data + Synchronize LDAP groups

- {% for group in active_members|groupby("type") %} -
-

Active members, {{ group.grouper }}:

- - - - - - - - {% for member in group.list %} - - - - - - - {% endfor %} -
#LDAP UsernameMonths DuePayment Policy
{{loop.index}}. - - {{member.ldap_username}} - - - - {{member.months_due}} - - {% include "button_payment_policy.html" %}
-
- {% endfor %} -
-

Inactive members:

- - - - - - - - {% for member in inactive_members %} - - - - - - - {% endfor %} -
#LDAP UsernameMonths DuePayment Policy
{{loop.index}}. - - {{member.ldap_username}} - - - - {{member.months_due}} - - {% include "button_payment_policy.html" %}
-
+
+
+ {% for group in active_members|groupby("type") %} +
+

Active members, {{ group.grouper }}:

+ + + + + + + + {% for member in group.list %} + + + + + + + {% endfor %} +
#LDAP UsernameMonths DuePayment Policy
{{loop.index}}. + + {{member.username}} + + + + {{member.months_due}} + + {% include "button_payment_policy.html" %}
+
+ {% endfor %} +
+

Inactive members:

+ + + + + + + + {% for member in inactive_members %} + + + + + + + {% endfor %} +
#LDAP UsernameMonths DuePayment Policy
{{loop.index}}. + + {{member.username}} + + + + {{member.months_due}} + + {% include "button_payment_policy.html" %}
+
+
+
{% endblock %} diff --git a/web/webapp/templates/admin_ldap_sync.html b/web/webapp/templates/admin_ldap_sync.html new file mode 100644 index 0000000..d624547 --- /dev/null +++ b/web/webapp/templates/admin_ldap_sync.html @@ -0,0 +1,58 @@ +{% extends "root.html" %} +{% set active_page = "admin" %} +{% block title %}Admin LDAP Sync{% endblock %} +{% block content %} + +
+
+
+

Active operations:

+

Available operations:

+

+ + Fetch transfer data + Synchronize LDAP groups +

+
+
+
+ {% if not form %} +
+

No sync required - groups are up to date.

+
+ {% else %} +
+
+ +
+
+

Fatty to add:

+ {{ form.fatty_to_add() }} +
+
+

Fatty to remove:

+ {{ form.fatty_to_remove() }} +
+
+

Starving to add:

+ {{ form.starving_to_add() }} +
+
+

Starving to remove:

+ {{ form.starving_to_remove() }} +
+
+ +
+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/web/webapp/templates/admin_member.html b/web/webapp/templates/admin_member.html index ca44fa3..d014fe4 100644 --- a/web/webapp/templates/admin_member.html +++ b/web/webapp/templates/admin_member.html @@ -5,13 +5,13 @@ {% set active_page = "profile" %} {% endif %} -{% block title %}{% if admin %}{{member.ldap_username}}{%else%}Profile{%endif%}{% endblock %} +{% block title %}{% if admin %}{{member.username}}{%else%}Profile{%endif%}{% endblock %} {% block content %}
gravatar -

{{member.ldap_username}}{%if cn %}
{{cn}}{% endif%}

+

{{member.username}}{%if cn %}
{{cn}}{% endif%}

{{member.get_contact_email()}}

{% if status.judgement %} @@ -62,7 +62,7 @@ (which is overdue), so is INACTIVE. {% endif %} {% else %} - . {{member.ldap_username}} Is in Extended Grace Period, so is ACTIVE. + . {{member.username}} Is in Extended Grace Period, so is ACTIVE. {% endif %} {% endif %} {% endif %} diff --git a/web/webapp/templates/button_membership_type.html b/web/webapp/templates/button_membership_type.html index 0eb74a5..8755de8 100644 --- a/web/webapp/templates/button_membership_type.html +++ b/web/webapp/templates/button_membership_type.html @@ -7,8 +7,8 @@ {{member.type|capitalize}}

diff --git a/web/webapp/templates/button_payment_policy.html b/web/webapp/templates/button_payment_policy.html index 73a7c9f..ad0b0aa 100644 --- a/web/webapp/templates/button_payment_policy.html +++ b/web/webapp/templates/button_payment_policy.html @@ -9,9 +9,9 @@ {{member.payment_policy}}
diff --git a/web/webapp/templates/memberlist.html b/web/webapp/templates/memberlist.html index 94621da..59869a9 100644 --- a/web/webapp/templates/memberlist.html +++ b/web/webapp/templates/memberlist.html @@ -17,7 +17,7 @@ {% for member in active_members %} - {{member['ldap_username']}} + {{member['username']}} {{member['type']}} {{member['joined'][0]}}/{{"%02i" |format(member['joined'][1])}} diff --git a/web/webapp/views.py b/web/webapp/views.py index eb39e60..390b9ae 100644 --- a/web/webapp/views.py +++ b/web/webapp/views.py @@ -34,11 +34,11 @@ from subprocess import Popen, PIPE from webapp import app, forms, User, db, models, mc, cache_enabled, admin_required from flask.ext.login import login_user, login_required, logout_user, current_user -from flask import request, redirect, flash, render_template, url_for, abort, g +from flask import Response, request, redirect, flash, render_template, url_for, abort, g import banking import logic import directory - +import traceback @app.route('/') def stats(): @@ -64,11 +64,11 @@ def memberlist(): @app.route('/profile') @login_required def self_profile(): - member = models.Member.get_members(True).filter_by(ldap_username=current_user.username).first() + member = models.Member.get_members(True).filter_by(username=current_user.username).first() if not member: abort(404) status = member.get_status() - cn = directory.get_member_fields(g.ldap, member.ldap_username, 'cn')['cn'] + cn = directory.get_member_fields(g.ldap, member.username, 'cn')['cn'] return render_template("admin_member.html", member=member, status=status, cn=cn, admin=False) @@ -88,33 +88,70 @@ def admin_index(): active_members = filter(lambda m: m['judgement'], members) inactive_members = filter(lambda m: not m['judgement'], members) - + diff = directory.get_ldap_group_diff(members) + if diff is not None: + flash("LDAP sync required") return render_template("admin_index.html", active_members=active_members, inactive_members=inactive_members) +@app.route("/admin/ldapsync", methods=["POST", "GET"]) +@admin_required +@login_required +def admin_ldap_sync(): + members = [m.get_status() for m in models.Member.get_members(True)] + diff = directory.get_ldap_group_diff(members) + if diff is None: + return render_template("admin_ldap_sync.html", form=False) + + form = forms.LDAPSyncForm(request.form) + + form.fatty_to_add.choices = zip(diff['fatty_to_add'],diff['fatty_to_add']) + form.fatty_to_add.default = diff['fatty_to_add'] + + form.fatty_to_remove.choices = zip(diff['fatty_to_remove'],diff['fatty_to_remove']) + form.fatty_to_remove.default = diff['fatty_to_remove'] + + form.starving_to_add.choices = zip(diff['starving_to_add'],diff['starving_to_add']) + form.starving_to_add.default = diff['starving_to_add'] + + form.starving_to_remove.choices = zip(diff['starving_to_remove'],diff['starving_to_remove']) + form.starving_to_remove.default = diff['starving_to_remove'] + + form.process(request.form) + if request.method == "POST" and form.validate(): + changes = {'fatty': {}, 'starving': {}} + changes['fatty']['add'] = form.fatty_to_add.data + changes['fatty']['remove'] = form.fatty_to_remove.data + changes['starving']['add'] = form.starving_to_add.data + changes['starving']['remove'] = form.starving_to_remove.data + + directory.update_member_groups(g.ldap, changes) + + return render_template("admin_ldap_sync.html", form=form) + + @app.route("/admin/csv") @admin_required @login_required def admin_csv(): members = [m.get_status() for m in models.Member.get_members(True)] for member in members: - member["cn"] = directory.get_member_fields(g.ldap, member.ldap_username, 'cn')['cn'] + member["cn"] = directory.get_member_fields(g.ldap, member['username'], 'cn')['cn'] active_members = filter(lambda m: m['judgement'] and not m['type'] == 'supporting', members) - - return render_template("admin_csv.html", - active_members=active_members) + output = render_template("admin_csv.html", active_members=active_members) + return Response(output) @app.route('/admin/member/') @login_required @admin_required def admin_member(membername): - member = models.Member.get_members(True).filter_by(ldap_username=membername).first() + member = models.Member.get_members(True).filter_by(username=membername).first() if not member: abort(404) status = member.get_status() - cn = directory.get_member_fields(g.ldap, member.ldap_username, 'cn')['cn'] + cn = directory.get_member_fields(g.ldap, member.username, 'cn')['cn'] return render_template("admin_member.html", member=member, status=status, cn=cn, admin=True) @@ -122,7 +159,7 @@ def admin_member(membername): @login_required @admin_required def admin_member_set_policy(membername,policy): - member = models.Member.query.filter_by(ldap_username=membername).first() + member = models.Member.query.filter_by(username=membername).first() member.payment_policy = models.PaymentPolicy[policy].value db.session.add(member) db.session.commit() @@ -132,7 +169,7 @@ def admin_member_set_policy(membername,policy): @login_required @admin_required def admin_member_set_membership(membername,membershiptype): - member = models.Member.query.filter_by(ldap_username=membername).first() + member = models.Member.query.filter_by(username=membername).first() member.type = models.MembershipType[membershiptype].name db.session.add(member) db.session.commit() @@ -168,7 +205,7 @@ def admin_fetch(): except Exception as e: exc_type, exc_value, exc_traceback = sys.exc_info() - flash("Error when fetching data. %s" % traceback.format_exception(exc_type, exc_value,exc_traceback)) + flash("Error when fetching data.
%s
" % traceback.format_exception(exc_type, exc_value,exc_traceback)) return redirect(url_for("admin_fetch")) logic.update_transfer_rows() @@ -185,17 +222,20 @@ def admin_match_auto(): left = 0 transfers_unmatched = logic.get_unmatched_transfers() for transfer in transfers_unmatched: - matchability, extra = transfer.get_matchability() + matchability, member, months = transfer.get_matchability() if matchability == models.Transfer.MATCH_OK: - member = extra if len(member.transfers) > 0: year, month = member.get_next_unpaid() else: year, month = transfer.date.year, transfer.date.month - mt = models.MemberTransfer(None, year, month, transfer) - member.transfers.append(mt) - db.session.add(mt) + for m in range(months): + mt = models.MemberTransfer(None, year, month, transfer) + member.transfers.append(mt) + db.session.add(mt) + flash("Matched transfer {} to member {} for month {}-{}".format(transfer.id, member.username, year, month)) + year, month = member._yearmonth_increment((year,month)) matched += 1 + member.invalidate_cache() else: left += 1 db.session.commit()