diff --git a/js/forms.js b/js/forms.js index 8dac80b602..05ca81cd3d 100644 --- a/js/forms.js +++ b/js/forms.js @@ -16,7 +16,18 @@ Liberapay.forms.jsSubmit = function() { function submit(e) { e.preventDefault(); var $form = $(this.form || this); + var target = $form.attr('action'); + var js_only = target == 'javascript:'; var data = $form.serializeArray(); + if (js_only) { + // workaround for http://stackoverflow.com/q/11424037/2729778 + $form.find('input[type="checkbox"]').each(function () { + var $input = $(this); + if (!$input.prop('checked')) { + data.push({name: $input.attr('name'), value: 'off'}); + } + }); + } var button = this.tagName == 'BUTTON' ? this : null; if (this.tagName == 'BUTTON') { data.push({name: this.name, value: this.value}); @@ -24,7 +35,7 @@ Liberapay.forms.jsSubmit = function() { var $inputs = $form.find(':not(:disabled)'); $inputs.prop('disabled', true); jQuery.ajax({ - url: $form.attr('action'), + url: js_only ? '' : target, type: 'POST', data: data, dataType: 'json', diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index c473c560b5..020a3f351f 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -1783,6 +1783,26 @@ def controls(self, other): other.kind == 'group' and self.member_of(other) ) + def update_bit(self, column, bit, on): + """Updates one bit in an integer in the participants table. + + Bits are used for email notification preferences and privacy settings. + """ + assert isinstance(getattr(self, column), int) # anti sql injection + if on: + mask = bit + op = '|' + else: + mask = 2147483647 ^ bit + op = '&' + r = self.db.one(""" + UPDATE participants + SET {column} = {column} {op} %s + WHERE id = %s + RETURNING {column} + """.format(column=column, op=op), (mask, self.id)) + self.set_attributes(**{column: r}) + class NeedConfirmation(Exception): """Represent the case where we need user confirmation during a merge. diff --git a/liberapay/utils/__init__.py b/liberapay/utils/__init__.py index a8715a9b36..6fff68c6f9 100644 --- a/liberapay/utils/__init__.py +++ b/liberapay/utils/__init__.py @@ -56,8 +56,15 @@ def get_participant(state, restrict=True, redirect_stub=True, allow_member=False if participant is None: from liberapay.models.participant import Participant # avoid circular import participant = Participant._from_thing(thing, value) if value else None - if participant is None or participant.kind == 'community': + if participant is None: raise response.error(404) + elif participant.kind == 'community': + c_name = Participant.db.one(""" + SELECT name + FROM communities + WHERE participant = %s + """, (participant.id,)) + raise response.redirect('/for/%s' % c_name) if redirect_canon and request.method in ('GET', 'HEAD'): if slug != participant.username: diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..8fde9c22e4 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,42 @@ +BEGIN; + + LOCK TABLE participants IN EXCLUSIVE MODE; + + DROP VIEW sponsors; + + ALTER TABLE participants + ALTER COLUMN profile_noindex DROP DEFAULT, + ALTER COLUMN profile_noindex SET DATA TYPE int USING (profile_noindex::int | 2), + ALTER COLUMN profile_noindex SET DEFAULT 2; + + ALTER TABLE participants + ALTER COLUMN hide_from_lists DROP DEFAULT, + ALTER COLUMN hide_from_lists SET DATA TYPE int USING (hide_from_lists::int), + ALTER COLUMN hide_from_lists SET DEFAULT 0; + + ALTER TABLE participants + ALTER COLUMN hide_from_search DROP DEFAULT, + ALTER COLUMN hide_from_search SET DATA TYPE int USING (hide_from_search::int), + ALTER COLUMN hide_from_search SET DEFAULT 0; + + LOCK TABLE communities IN EXCLUSIVE MODE; + UPDATE participants p + SET hide_from_lists = c.is_hidden::int + FROM communities c + WHERE c.participant = p.id; + ALTER TABLE communities DROP COLUMN is_hidden; + + CREATE OR REPLACE VIEW sponsors AS + SELECT * + FROM participants p + WHERE status = 'active' + AND kind = 'organization' + AND giving > receiving + AND giving >= 10 + AND hide_from_lists = 0 + AND profile_noindex = 0 + ; + +END; + +UPDATE participants SET profile_nofollow = true; diff --git a/style/base/base.scss b/style/base/base.scss index d312ca4bcd..74259bb471 100644 --- a/style/base/base.scss +++ b/style/base/base.scss @@ -216,6 +216,23 @@ li.mini-user { } } +.user-admin { + border-bottom: 1px solid #ddd; + padding-bottom: 1em; +} +.mini-user-admin { + border-right: 1px solid #ddd; + text-align: center; + img.avatar { + max-width: 110px; + } + .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + #subnav + p, #subnav + div.paragraph { margin-top: 1.5em; } diff --git a/templates/privacy-form.html b/templates/privacy-form.html index 0d9e14ad6f..3ae24e6a0d 100644 --- a/templates/privacy-form.html +++ b/templates/privacy-form.html @@ -4,14 +4,22 @@
+ % set has_override = set() % for name, label in constants.PRIVACY_FIELDS.items() + % if participant[name] == 2 + {{ has_override.add(True) or '' }} + ({{ _("Admin override is on.*") }}) + % endif
% endfor
+ % if has_override +

{{ _("*The referencing of Liberapay profiles is subject to admin approval.") }}

+ % endif % endmacro diff --git a/tests/py/test_search.py b/tests/py/test_search.py index e556597a45..8dc0e554f6 100644 --- a/tests/py/test_search.py +++ b/tests/py/test_search.py @@ -32,7 +32,7 @@ def test_get_fuzzy_match(self): assert data[0]['username'] == 'alice' def test_hide_from_search(self): - self.make_participant('alice', hide_from_search=True) + self.make_participant('alice', hide_from_search=1) response = self.client.GET('/search.json?q=alice&scope=usernames') data = json.loads(response.text)['usernames'] assert data == [] diff --git a/tests/py/test_settings.py b/tests/py/test_settings.py index ac6ec63110..42b7ac78a1 100644 --- a/tests/py/test_settings.py +++ b/tests/py/test_settings.py @@ -27,13 +27,13 @@ def test_participant_can_modify_privacy_settings(self): self.hit_edit(data=ALL_ON) alice = Participant.from_id(self.alice.id) for k in PRIVACY_FIELDS: - assert getattr(alice, k) is True + assert getattr(alice, k) in (1, 3, True) # turn them all off self.hit_edit(data=ALL_OFF) alice = Participant.from_id(self.alice.id) for k in PRIVACY_FIELDS: - assert getattr(alice, k) is False + assert getattr(alice, k) in (0, 2, False) # Related to is-searchable @@ -49,7 +49,7 @@ def test_team_participant_does_show_up_on_explore_teams(self): def test_team_participant_doesnt_show_up_on_explore_teams(self): alice = Participant.from_username('alice') - self.make_participant('A-Team', kind='group', hide_from_lists=True).add_member(alice) + self.make_participant('A-Team', kind='group', hide_from_lists=1).add_member(alice) assert 'A-Team' not in self.client.GET("/explore/teams/").text diff --git a/www/%username/emails/notifications.json.spt b/www/%username/emails/notifications.json.spt index e10c46ebb0..0855e4a464 100644 --- a/www/%username/emails/notifications.json.spt +++ b/www/%username/emails/notifications.json.spt @@ -12,17 +12,7 @@ for field in fields: event = EVENTS.get(field) if not event: continue - if body.get(field) == 'on': - mask = event.bit - op = '|' - else: - mask = 2147483647 ^ event.bit - op = '&' - website.db.run(""" - UPDATE participants - SET email_notif_bits = email_notif_bits {0} %s - WHERE id = %s - """.format(op), (mask, p_id)) + participant.update_bit('email_notif_bits', event.bit, body.get(field) == 'on') if request.headers.get(b'X-Requested-With') != b'XMLHttpRequest': response.redirect('.') diff --git a/www/%username/giving/index.html.spt b/www/%username/giving/index.html.spt index b5353c73b0..2c11878105 100644 --- a/www/%username/giving/index.html.spt +++ b/www/%username/giving/index.html.spt @@ -39,6 +39,7 @@ npledges = len(pledges) if not freeload: Liberapay = Participant.from_username('Liberapay') + Liberapay_goal = getattr(Liberapay, 'goal', None) Liberapay_tip = participant.get_tip_to(getattr(Liberapay, 'id', -1)) weekly = total - participant.receiving @@ -89,7 +90,7 @@ weekly = total - participant.receiving ) }}

{{ tip_form(tip=Liberapay_tip, inline=True) }}

- % if Liberapay and Liberapay.receiving < Liberapay.goal * Decimal('0.5') + % if Liberapay_goal and Liberapay.receiving < Liberapay_goal * Decimal('0.5')

{{ _( "Building Liberapay is a lot of work, and there still is much to " "do, but our developers, translators, and other contributors are " diff --git a/www/%username/settings/edit.spt b/www/%username/settings/edit.spt index 80fbfcd6a4..eb2b9291ef 100644 --- a/www/%username/settings/edit.spt +++ b/www/%username/settings/edit.spt @@ -33,11 +33,14 @@ elif 'privacy' in body: if field not in PRIVACY_FIELDS: continue value = body.get(field) == 'on' - website.db.run(""" - UPDATE participants - SET {0} = %s - WHERE id = %s - """.format(field), (value, p.id)) + if isinstance(getattr(p, field), bool): + website.db.run(""" + UPDATE participants + SET {0} = %s + WHERE id = %s + """.format(field), (value, p.id)) + else: + p.update_bit(field, 1, value) out['msg'] = _("Your privacy settings have been changed.") elif 'username' in body: diff --git a/www/admin/users.spt b/www/admin/users.spt new file mode 100644 index 0000000000..34f2fadb17 --- /dev/null +++ b/www/admin/users.spt @@ -0,0 +1,91 @@ +# coding: utf8 + +import json + +from liberapay.exceptions import LoginRequired +from liberapay.models.participant import Participant + +def parse_int(o, **kw): + try: + return int(o) + except (ValueError, TypeError): + if 'default' in kw: + return kw['default'] + raise response.error(400, "'%s' is not a valid integer" % o) + +REFERENCING_ATTRS = ('profile_noindex', 'hide_from_lists', 'hide_from_search') + +[---] + +if user.ANON: + raise LoginRequired + +if not user.is_admin: + raise response.error(403) + +if request.method == 'POST': + p = Participant.from_id(request.body['p_id']) + updated = 0 + for attr in REFERENCING_ATTRS: + value = request.body.get(attr) + if value is None: + continue + p.update_bit(attr, 2, value == 'on') + updated += 1 + raise response.success(200, json.dumps({'msg': "Done, %i bits have been updated." % updated})) + +participants = website.db.all(""" + SELECT p + , (SELECT c.name FROM communities c WHERE c.participant = p.id) AS c_name + FROM participants p + WHERE p.id < %s + AND (p.status <> 'stub' OR p.receiving > 0) + ORDER BY p.id DESC + LIMIT 150 +""", (parse_int(request.qs.get('last_showed'), default=float('inf')),)) +last_id = participants[-1][0].id if participants else 0 + +title = "Users Admin" + +[---] text/html +% from 'templates/avatar-url.html' import avatar_img with context + +% extends "templates/base.html" + +% block content + +

{{ _("JavaScript is required") }}
+ +% for p, c_name in participants +
+
+ +
{{ c_name if c_name else p.username }}
({{ p.kind }}, {{ p.status }})
+ {{ avatar_img(p) }} +
{{ to_age_str(p.join_time, add_direction=True) if p.join_time }}
+
+
+
+
+ + % for attr in REFERENCING_ATTRS + % set value = getattr(p, attr) + +
+ % endfor + +
+
+
+
+
+% endfor + +% if last_id > 1 +{{ _("Next") }} → +% endif + +% endblock diff --git a/www/explore/communities/index.spt b/www/explore/communities/index.spt index 5c241b7c0d..c47fa1b859 100644 --- a/www/explore/communities/index.spt +++ b/www/explore/communities/index.spt @@ -17,8 +17,9 @@ communities_top = query_cache.all(""" LIMIT 1 ) AS subtitle FROM communities c + JOIN participants cp ON cp.id = c.participant WHERE (nmembers > 1 OR nsubscribers > 1) - AND NOT is_hidden + AND cp.hide_from_lists = 0 ORDER BY nmembers DESC, random() LIMIT 15 """, (' '.join(request.accept_langs),)) @@ -33,8 +34,9 @@ communities_loc = query_cache.all(""" AND s.lang = c.lang ) AS subtitle FROM communities c + JOIN participants cp ON cp.id = c.participant WHERE lang = %s - AND NOT is_hidden + AND cp.hide_from_lists = 0 ORDER BY nmembers DESC, random() LIMIT 15 """, (([l for l in request.accept_langs if l in communities_langs] + [''])[0],)) diff --git a/www/explore/pledges/index.spt b/www/explore/pledges/index.spt index f1973902fb..82d459effe 100644 --- a/www/explore/pledges/index.spt +++ b/www/explore/pledges/index.spt @@ -9,6 +9,7 @@ pledgees = website.db.all(""" JOIN elsewhere e ON e.participant = p.id WHERE p.status = 'stub' AND p.receiving > 0 + AND p.hide_from_lists = 0 ORDER BY ctime DESC LIMIT 20 """) diff --git a/www/explore/teams/index.spt b/www/explore/teams/index.spt index eb8706e937..6c83545860 100644 --- a/www/explore/teams/index.spt +++ b/www/explore/teams/index.spt @@ -13,7 +13,7 @@ teams = website.db.all(""" JOIN participants p ON p.id = t.id WHERE (p.goal >= 0 OR p.goal IS NULL) - AND NOT p.hide_from_lists + AND p.hide_from_lists = 0 ORDER BY receiving DESC, join_time DESC """) diff --git a/www/for/%name/index.html.spt b/www/for/%name/index.html.spt index a4cc50228d..977f57105b 100644 --- a/www/for/%name/index.html.spt +++ b/www/for/%name/index.html.spt @@ -8,6 +8,7 @@ query_cache = QueryCache(website.db, threshold=1) [---] community = get_community(state, restrict=False) +participant = community.participant # used by profile-base.html try: limit = min(int(request.qs.get('limit', 50)), 100) @@ -30,7 +31,7 @@ title = pretty_name = community.pretty_name [---] % from 'templates/avatar-url.html' import avatar_img, avatar_default with context -% extends "templates/base.html" +% extends "templates/profile-base.html" {% block before_content %}{% endblock %} diff --git a/www/on/%platform/%user_name/index.html.spt b/www/on/%platform/%user_name/index.html.spt index a131f6d6e5..3195e398be 100644 --- a/www/on/%platform/%user_name/index.html.spt +++ b/www/on/%platform/%user_name/index.html.spt @@ -37,7 +37,7 @@ if is_team: [-----------------------------------------------------------------------------] % from 'templates/avatar-url.html' import avatar_img with context -% extends "templates/base.html" +% extends "templates/profile-base.html" {% block heading %}{% endblock %} diff --git a/www/search.spt b/www/search.spt index 944b9810ce..36029e6e13 100644 --- a/www/search.spt +++ b/www/search.spt @@ -19,7 +19,7 @@ if query: FROM participants WHERE username %% %(q)s AND status = 'active' - AND NOT hide_from_search + AND hide_from_search = 0 ORDER BY rank DESC, username LIMIT 10 """, locals()) @@ -54,7 +54,7 @@ if query: ) s JOIN participants p ON p.id = s.participant WHERE p.status = 'active' - AND NOT p.hide_from_search + AND p.hide_from_search = 0 GROUP BY p.id ORDER BY max_rank DESC """, locals())