0001-family-weekly-agenda-cell-56027.patch
combo/apps/family/migrations/0006_weekly_agenda_cell.py | ||
---|---|---|
1 |
import django.db.models.deletion |
|
2 |
from django.db import migrations, models |
|
3 | ||
4 | ||
5 |
class Migration(migrations.Migration): |
|
6 | ||
7 |
dependencies = [ |
|
8 |
('auth', '0011_update_proxy_permissions'), |
|
9 |
('data', '0047_auto_20210723_1318'), |
|
10 |
('family', '0005_familyinfoscell_template_name'), |
|
11 |
] |
|
12 | ||
13 |
operations = [ |
|
14 |
migrations.CreateModel( |
|
15 |
name='WeeklyAgendaCell', |
|
16 |
fields=[ |
|
17 |
( |
|
18 |
'id', |
|
19 |
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), |
|
20 |
), |
|
21 |
('placeholder', models.CharField(max_length=20)), |
|
22 |
('order', models.PositiveIntegerField()), |
|
23 |
('slug', models.SlugField(blank=True, verbose_name='Slug')), |
|
24 |
( |
|
25 |
'extra_css_class', |
|
26 |
models.CharField( |
|
27 |
blank=True, max_length=100, verbose_name='Extra classes for CSS styling' |
|
28 |
), |
|
29 |
), |
|
30 |
( |
|
31 |
'template_name', |
|
32 |
models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'), |
|
33 |
), |
|
34 |
('public', models.BooleanField(default=True, verbose_name='Public')), |
|
35 |
( |
|
36 |
'restricted_to_unlogged', |
|
37 |
models.BooleanField(default=False, verbose_name='Restrict to unlogged users'), |
|
38 |
), |
|
39 |
('last_update_timestamp', models.DateTimeField(auto_now=True)), |
|
40 |
('title', models.CharField(blank=True, max_length=150, verbose_name='Title')), |
|
41 |
('agenda_reference', models.CharField(max_length=128, verbose_name='Agenda')), |
|
42 |
('user_external_id_key', models.CharField(max_length=50)), |
|
43 |
('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')), |
|
44 |
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')), |
|
45 |
], |
|
46 |
options={ |
|
47 |
'verbose_name': 'Weekly agenda cell', |
|
48 |
}, |
|
49 |
), |
|
50 |
] |
combo/apps/family/models.py | ||
---|---|---|
14 | 14 |
# You should have received a copy of the GNU Affero General Public License |
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 |
from django.conf import settings |
|
18 |
from django.db import models |
|
17 | 19 |
from django.utils.translation import ugettext_lazy as _ |
18 | 20 | |
19 | 21 |
from combo.data.library import register_cell_class |
20 |
from combo.data.models import CellBase |
|
22 |
from combo.data.models import CellBase, JsonCellBase
|
|
21 | 23 | |
22 | 24 |
from .utils import get_family, is_family_enabled |
23 | 25 | |
... | ... | |
50 | 52 |
if response.status_code == 200: |
51 | 53 |
return {'family': response.json()} |
52 | 54 |
return {'error': _('An error occured while retrieving family details.')} |
55 | ||
56 | ||
57 |
@register_cell_class |
|
58 |
class WeeklyAgendaCell(JsonCellBase): |
|
59 |
title = models.CharField(_('Title'), max_length=150, blank=True) |
|
60 |
agenda_reference = models.CharField(_('Agenda'), max_length=128) |
|
61 |
user_external_id_key = models.CharField(max_length=50) |
|
62 | ||
63 |
default_template_name = 'family/weekly_agenda.html' |
|
64 | ||
65 |
class Meta: |
|
66 |
verbose_name = _('Weekly agenda cell') |
|
67 | ||
68 |
@classmethod |
|
69 |
def is_enabled(cls): |
|
70 |
return settings.PUBLIK_FAMILY_CELL_ENABLED |
|
71 | ||
72 |
@property |
|
73 |
def url(self): |
|
74 |
chrono = list(settings.KNOWN_SERVICES.get('chrono').values())[0] |
|
75 |
chrono_url = chrono.get('url') or '' |
|
76 |
if not chrono_url.endswith('/'): |
|
77 |
chrono_url += '/' |
|
78 |
# XXX events=all param is not supported for now |
|
79 |
return ( |
|
80 |
'%sapi/agendas/datetimes/?agendas=%s&user_external_id=%s:{{ user_external_id|default:user_nameid }}' |
|
81 |
% (chrono_url, self.agenda_reference, self.user_external_id_key) |
|
82 |
) |
|
83 | ||
84 |
def is_visible(self, **kwargs): |
|
85 |
user = kwargs.get('user') |
|
86 |
if not user or user.is_anonymous: |
|
87 |
return False |
|
88 |
return super().is_visible(**kwargs) |
|
89 | ||
90 |
def get_cell_extra_context(self, context): |
|
91 |
if context.get('placeholder_search_mode'): |
|
92 |
return {} |
|
93 |
return super().get_cell_extra_context(context) |
combo/apps/family/templates/family/weekly_agenda.html | ||
---|---|---|
1 |
{% load i18n %} |
|
2 |
{% block cell-content %} |
|
3 |
{% if json.data %} |
|
4 | ||
5 |
{% if cell.title %} |
|
6 |
<h2> |
|
7 |
{{ cell.title }} |
|
8 |
</h2> |
|
9 |
{% endif %} |
|
10 | ||
11 |
<div class="weeklyagenda-cell weeklyagenda-cell-{{ cell.pk }}"> |
|
12 |
{% with first_monday=json.data.0.date|date|adjust_to_week_monday last_item=json.data|last %} |
|
13 |
{% with last_day=last_item.date|date|adjust_to_week_monday|add_days:6 %} |
|
14 |
{% now 'Y-m-d' as now %} |
|
15 | ||
16 |
{% spaceless %} |
|
17 |
<button class="previous-week">←</button> |
|
18 |
<ul> |
|
19 |
{% for day in first_monday|iterate_days_until:last_day %} |
|
20 |
{% if day.weekday == 0 %} |
|
21 |
{% with sunday=day|add_days:6 %} |
|
22 |
<li class="week"><span class="week-title">{% blocktrans with day_date=day|date:"d/m" sunday_date=sunday|date:"d/m" %}Semaine<br class="weekbreak"> du {{ day_date }} au {{ sunday_date }}{% endblocktrans %}</span><ul> |
|
23 |
{% endwith %} |
|
24 |
{% endif %} |
|
25 |
<li class="day-title {% if now == day|date:"Y-m-d" %}current{% endif %}" data-weekday="{{ day.weekday }}"><strong>{{ day|date:"l d/m" }}</strong></li> |
|
26 |
{% with day_str=day|date:"Y-m-d" %} |
|
27 |
{% for item in json.data %} |
|
28 |
{% if item.date == day_str %} |
|
29 | ||
30 |
<li class="activity {% if item.disabled %}disabled{% endif %}" |
|
31 |
{% if item.booked_for_external_user %}data-status="green"{% endif %} |
|
32 |
><span><span>{{ item.label }}</span></span> |
|
33 |
</li> |
|
34 | ||
35 |
{% endif %} |
|
36 |
{% endfor %} |
|
37 |
{% endwith %} |
|
38 |
{% if day.weekday == 6 %} |
|
39 |
</ul> |
|
40 |
<p class="no-activity">{% trans "No activity this week" %}</p> |
|
41 |
</li> |
|
42 |
{% endif %} |
|
43 |
{% endfor %} |
|
44 |
</ul> |
|
45 |
<button class="next-week">→</button> |
|
46 |
{% endspaceless %} |
|
47 |
{% endwith %} |
|
48 |
{% endwith %} |
|
49 | ||
50 |
<style> |
|
51 |
.weeklyagenda-cell { |
|
52 |
margin-top: 1em; |
|
53 |
display: flex; |
|
54 |
} |
|
55 |
.weeklyagenda-cell ul { |
|
56 |
padding: 0; |
|
57 |
margin: 0; |
|
58 |
} |
|
59 |
.weeklyagenda-cell > ul { |
|
60 |
width: 100%; |
|
61 |
} |
|
62 |
.weeklyagenda-cell > ul > li { |
|
63 |
display: none; |
|
64 |
} |
|
65 |
.weeklyagenda-cell > ul > li.shown { |
|
66 |
display: block; |
|
67 |
} |
|
68 |
.weeklyagenda-cell button.previous-week, |
|
69 |
.weeklyagenda-cell button.next-week { |
|
70 |
height: 3em; |
|
71 |
z-index: 10; |
|
72 |
} |
|
73 |
.weeklyagenda-cell button.next-week { |
|
74 |
margin-left: 1em; |
|
75 |
margin-right: 0; |
|
76 |
} |
|
77 |
.weeklyagenda-cell .week-title { |
|
78 |
display: block; |
|
79 |
height: 3em; |
|
80 |
line-height: 3em; |
|
81 |
} |
|
82 |
.weeklyagenda-cell .no-activity { |
|
83 |
color: #888; |
|
84 |
} |
|
85 |
.weeklyagenda-cell li.day-title { |
|
86 |
margin: 1em 0 0.5em 0; |
|
87 |
} |
|
88 |
.weeklyagenda-cell ul ul { |
|
89 |
list-style: none; |
|
90 |
} |
|
91 |
.weeklyagenda-cell li.disabled { |
|
92 |
color: #888; |
|
93 |
} |
|
94 |
.weeklyagenda-cell li > span { |
|
95 |
position: relative; |
|
96 |
} |
|
97 |
.weeklyagenda-cell span > span { |
|
98 |
padding-left: 1.8em; |
|
99 |
} |
|
100 |
.weeklyagenda-cell span > span::before { |
|
101 |
display: block; |
|
102 |
content: ''; |
|
103 |
position: absolute; |
|
104 |
margin: auto; |
|
105 |
height: calc(0.66rem + 2px); |
|
106 |
width: calc(0.66rem + 2px); |
|
107 |
background: transparent; |
|
108 |
top: 0.33rem; |
|
109 |
left: 0; |
|
110 |
border: 1px solid #aaa; |
|
111 |
border-radius: 2px; |
|
112 |
} |
|
113 |
.weeklyagenda-cell span > span::after { |
|
114 |
display: block; |
|
115 |
content: ''; |
|
116 |
position: absolute; |
|
117 |
margin: auto; |
|
118 |
height: calc(0.66rem); |
|
119 |
width: calc(0.66rem); |
|
120 |
background: transparent; |
|
121 |
transition: background 0.1s linear; |
|
122 |
top: calc(0.33rem + 1px); |
|
123 |
left: 1px; |
|
124 |
} |
|
125 |
.weeklyagenda-cell [data-status=green] span > span::after { |
|
126 |
background: #3c3; |
|
127 |
} |
|
128 | ||
129 |
br.weekbreak { display: none; } |
|
130 | ||
131 |
@media screen and (max-width: 500px) { |
|
132 | ||
133 |
br.weekbreak { display: block; } |
|
134 | ||
135 |
div.weeklyagenda-cell > ul { |
|
136 |
margin: 0 -4em; |
|
137 |
} |
|
138 |
div.weeklyagenda-cell > ul .week-title { |
|
139 |
margin: 0 4em; |
|
140 |
line-height: 150%; |
|
141 |
text-align: center; |
|
142 |
} |
|
143 | ||
144 |
} |
|
145 |
</style> |
|
146 |
<script> |
|
147 |
$('.weeklyagenda-cell-{{ cell.pk }} .previous-week').on('click', function() { |
|
148 |
var $cell = $(this).parents('.weeklyagenda-cell-{{ cell.pk }}'); |
|
149 |
var $prev = $('li.shown', $cell).prev(); |
|
150 |
if ($prev.length) { $('li.shown', $cell).removeClass('shown'); $prev.addClass('shown'); } |
|
151 |
return false; |
|
152 |
}); |
|
153 |
$('.weeklyagenda-cell-{{ cell.pk }} .next-week').on('click', function() { |
|
154 |
var $cell = $(this).parents('.weeklyagenda-cell-{{ cell.pk }}'); |
|
155 |
var $next = $('li.shown', $cell).next(); |
|
156 |
if ($next.length) { $('li.shown', $cell).removeClass('shown'); $next.addClass('shown'); } |
|
157 |
return false; |
|
158 |
}); |
|
159 |
$('.weeklyagenda-cell-{{ cell.pk }} li.day-title').each(function(idx, elem) { |
|
160 |
/* hide empty days */ |
|
161 |
var $next = $(elem).next(); |
|
162 |
if ($next.length == 0 || $next.is('.day-title')) { |
|
163 |
$(elem).hide(); |
|
164 |
} |
|
165 |
}); |
|
166 |
$('.weeklyagenda-cell-{{ cell.pk }} li.week').each(function(idx, elem) { |
|
167 |
/* hide no-activity message if not empty */ |
|
168 |
if ($('.activity', $(elem)).length > 0) { |
|
169 |
$('.no-activity', $(elem)).hide(); |
|
170 |
} |
|
171 |
/* hide booking button if all items are disabled */ |
|
172 |
if ($('.activity', $(elem)).not('.disabled').length == 0) { |
|
173 |
$('.booking-btn', $(elem)).hide(); |
|
174 |
} |
|
175 |
}); |
|
176 |
$('.weeklyagenda-cell-{{ cell.pk }}').each(function(idx, elem) { |
|
177 |
/* init first week shown */ |
|
178 |
var $cell = $(this); |
|
179 |
$('li', $cell).removeClass('shown'); |
|
180 |
if ($('li.day-title.current', $cell).length) { |
|
181 |
$('li.day-title.current', $cell).parent().parent().addClass('shown'); |
|
182 |
} else { |
|
183 |
$('li', $cell).first().addClass('shown'); |
|
184 |
} |
|
185 |
}); |
|
186 | ||
187 |
</script> |
|
188 | ||
189 |
{% endif %} |
|
190 |
{% endblock %} |
combo/settings.py | ||
---|---|---|
366 | 366 |
# hide work-in-progress/experimental/broken/legacy/whatever cells for now |
367 | 367 |
BOOKING_CALENDAR_CELL_ENABLED = False |
368 | 368 |
LEGACY_CHART_CELL_ENABLED = False |
369 |
PUBLIK_FAMILY_CELL_ENABLED = False |
|
369 | 370 | |
370 | 371 | |
371 | 372 |
def debug_show_toolbar(request): |
tests/test_family.py | ||
---|---|---|
1 |
import json |
|
2 |
from unittest import mock |
|
3 | ||
4 |
import pytest |
|
5 |
from django.core.cache import cache |
|
6 |
from django.test.client import RequestFactory |
|
7 | ||
8 |
from combo.apps.family.models import WeeklyAgendaCell |
|
9 |
from combo.data.models import Page |
|
10 |
from combo.utils import NothingInCacheException |
|
11 | ||
12 |
pytestmark = pytest.mark.django_db |
|
13 | ||
14 | ||
15 |
@pytest.fixture |
|
16 |
def context(): |
|
17 |
ctx = {'request': RequestFactory().get('/')} |
|
18 |
ctx['request'].user = None |
|
19 |
ctx['request'].session = {} |
|
20 |
return ctx |
|
21 | ||
22 | ||
23 |
class MockUser: |
|
24 |
email = 'foo@example.net' |
|
25 |
is_authenticated = True |
|
26 |
is_anonymous = False |
|
27 | ||
28 |
def get_name_id(self): |
|
29 |
return None |
|
30 | ||
31 | ||
32 |
class MockUserWithNameId: |
|
33 |
email = 'foo@example.net' |
|
34 |
is_authenticated = True |
|
35 |
is_anonymous = False |
|
36 | ||
37 |
def get_name_id(self): |
|
38 |
return 'xyz' |
|
39 | ||
40 | ||
41 |
class MockedRequestResponse(mock.Mock): |
|
42 |
status_code = 200 |
|
43 | ||
44 |
def json(self): |
|
45 |
return json.loads(self.content) |
|
46 | ||
47 | ||
48 |
def test_weeklyagenda_cell(settings, context): |
|
49 |
settings.PUBLIK_FAMILY_CELL_ENABLED = True |
|
50 | ||
51 |
page = Page.objects.create(title='Family', slug='index', template_name='standard') |
|
52 |
cell = WeeklyAgendaCell.objects.create(page=page, placeholder='content', order=0) |
|
53 | ||
54 |
context['request'].user = MockUser() |
|
55 | ||
56 |
# query should fail as nothing is cached |
|
57 |
cache.clear() |
|
58 |
with pytest.raises(NothingInCacheException): |
|
59 |
cell.render(context) |
|
60 | ||
61 |
context['synchronous'] = True # to get fresh content |
|
62 | ||
63 |
data = {'data': []} |
|
64 |
with mock.patch('combo.utils.requests.get') as requests_get: |
|
65 |
requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) |
|
66 |
cell.render(context) |
|
67 |
# wrong url |
|
68 |
assert ( |
|
69 |
requests_get.call_args_list[0][0][0] |
|
70 |
== 'http://chrono.example.org/api/agendas/datetimes/?agendas=&user_external_id=:' |
|
71 |
) |
|
72 | ||
73 |
cell.agenda_reference = 'some-agenda' |
|
74 |
cell.save() |
|
75 |
context['request'].user = MockUserWithNameId() |
|
76 |
with mock.patch('combo.utils.requests.get') as requests_get: |
|
77 |
requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) |
|
78 |
cell.render(context) |
|
79 |
assert ( |
|
80 |
requests_get.call_args_list[0][0][0] |
|
81 |
== 'http://chrono.example.org/api/agendas/datetimes/?agendas=some-agenda&user_external_id=:xyz' |
|
82 |
) |
|
83 | ||
84 |
cell.user_external_id_key = 'some-key' |
|
85 |
cell.save() |
|
86 |
with mock.patch('combo.utils.requests.get') as requests_get: |
|
87 |
requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) |
|
88 |
cell.render(context) |
|
89 |
assert ( |
|
90 |
requests_get.call_args_list[0][0][0] |
|
91 |
== 'http://chrono.example.org/api/agendas/datetimes/?agendas=some-agenda&user_external_id=some-key:xyz' |
|
92 |
) |
tests/test_manager.py | ||
---|---|---|
880 | 880 |
resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json') |
881 | 881 |
with CaptureQueriesContext(connection) as ctx: |
882 | 882 |
resp = resp.form.submit() |
883 |
assert len(ctx.captured_queries) in [298, 299]
|
|
883 |
assert len(ctx.captured_queries) in [303, 304]
|
|
884 | 884 |
assert Page.objects.count() == 4 |
885 | 885 |
assert PageSnapshot.objects.all().count() == 4 |
886 | 886 | |
... | ... | |
891 | 891 |
resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json') |
892 | 892 |
with CaptureQueriesContext(connection) as ctx: |
893 | 893 |
resp = resp.form.submit() |
894 |
assert len(ctx.captured_queries) == 268
|
|
894 |
assert len(ctx.captured_queries) == 272
|
|
895 | 895 |
assert set(Page.objects.get(slug='one').related_cells['cell_types']) == {'data_textcell', 'data_linkcell'} |
896 | 896 |
assert Page.objects.count() == 4 |
897 | 897 |
assert LinkCell.objects.count() == 2 |
... | ... | |
2222 | 2222 | |
2223 | 2223 |
with CaptureQueriesContext(connection) as ctx: |
2224 | 2224 |
resp2 = resp.click('view', index=1) |
2225 |
assert len(ctx.captured_queries) == 70
|
|
2225 |
assert len(ctx.captured_queries) == 71
|
|
2226 | 2226 |
assert Page.snapshots.latest('pk').related_cells == {'cell_types': ['data_textcell']} |
2227 | 2227 |
assert resp2.text.index('Hello world') < resp2.text.index('Foobar3') |
2228 | 2228 | |
... | ... | |
2283 | 2283 |
resp = resp.click('restore', index=6) |
2284 | 2284 |
with CaptureQueriesContext(connection) as ctx: |
2285 | 2285 |
resp = resp.form.submit().follow() |
2286 |
assert len(ctx.captured_queries) == 142
|
|
2286 |
assert len(ctx.captured_queries) == 144
|
|
2287 | 2287 | |
2288 | 2288 |
resp2 = resp.click('See online') |
2289 | 2289 |
assert resp2.text.index('Foobar1') < resp2.text.index('Foobar2') < resp2.text.index('Foobar3') |
2290 |
- |