diff --git a/AUTHORS.md b/AUTHORS.md index b121c61..163641c 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -24,3 +24,4 @@ * Matthew Bentley [matthewbentley](https://github.com/matthewbentley) * Kevin Hock [KevinHock](https://github.com/KevinHock) * Duncan Cook [theletterd](https://github.com/theletterd) +* Billy Montgomery [billyxs](https://github.com/billyxs) \ No newline at end of file diff --git a/README.md b/README.md index fb4d9a3..353fe33 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ to set up billing and give Cloud Build permission to deploy your app. ### Prepare for deployment -Copy the [example config](config-example.py) file to config.py and change the +Copy the [example config](loveapp/config-example.py) file to config.py and change the settings. Don't forget to specify your own SECRET_KEY. ### Initial deployment @@ -85,7 +85,7 @@ using the [Secret](models/secret.py) model. Locally, you can temporarily add an def create_secrets(): from loveapp.models import Secret Secret(id='AWS_ACCESS_KEY_ID', value='change-me').put() - Secret(id='AWS_SECRET_ACCESS_KEY', value='change-me').put() + Secret(id='AWS_SECRET_ACCESS_KEY', value='change-me').put() return "please delete me now" ``` @@ -110,11 +110,67 @@ your favorite packet manager. ### Running the application locally -* Check out the application code: git clone git@github.com:Yelp/love.git -* Follow the [Prepare for deployment](#prepare-for-deployment) section -* Run the app: make run-dev will start both [Yelp Love](http://localhost:8080) as well as the [Admin server](http://localhost:8000) -* Follow the [CSV import](#csv) section to locally import user data -* Make your changes +Check out the application code and cd to the directory +``` +$ git clone git@github.com:Yelp/love.git +$ cd love +``` + +Create the virtual environment and activate it +``` +$ python3 -m venv env +$ source env/bin/activate +``` + +Install the requisite libraries +``` +$ pip install -r requirements-dev.txt +``` + +Make a copy of loveapp/config.py +``` +$ cp loveapp/config-example.py loveapp/config.py +``` + +Make a copy of employees.csv.example +``` +cp import/employees.csv.example import/employees.csv +``` + + +Edit employees.csv to add your own test data +``` +username,first_name,last_name,department,office,photo_url +michael,Michael,Scott,,,https://placehold.co/100x100 +jim,Jim,Halpert,,,https://placehold.co/100x100 +pam,Pamela,Beesly,,,https://placehold.co/100x100 +dwight,Dwight,Schrute,,,https://placehold.co/100x100 +angela,Angela,Martin,,,https://placehold.co/100x100 +ryan,Ryan,Howard,,,https://placehold.co/100x100 +stanley,Stanley,Hudson,,,https://placehold.co/100x100 +kelly,Kelly,Kapoor,,,https://placehold.co/100x100 +oscar,Oscar,Martinez,,,https://placehold.co/100x100 +creed,Creed,Bratton,,,https://placehold.co/100x100 +kevin,Kevin,Malone,,,https://placehold.co/100x100 +toby,Toby,Flenderson,,,https://placehold.co/100x100 +phyllis,Phyllis,Lapin,,,https://placehold.co/100x100 +meredith,Meredith,Palmer,,,https://placehold.co/100x100 +andy,Andy,Bernard,,,https://placehold.co/100x100 +bobvance,Vance,Refrigeration,,,https://placehold.co/100x100 +``` + +Run the application +``` +$ make run-dev +``` + +Go to http://localhost:8080/ and login as michael@example.com (or something matching a username in employees.csv), check the "Sign in as administrator" box. +You'll probably get an error, but that's ok. + +Go to http://localhost:8080/employees/import +click "Import" + +You're done! ## Deployment @@ -218,14 +274,25 @@ curl "https://project_id.appspot.com/api/autocomplete?term=ha&api_key=secret" ## Original Authors and Contributors -* [adamrothman](https://github.com/adamrothman) -* [amartinezfonts](https://github.com/amartinezfonts) -* [benasher44](https://github.com/benasher44) -* [jetze](https://github.com/jetze) -* [KurtisFreedland](https://github.com/KurtisFreedland) -* [mesozoic](https://github.com/mesozoic) -* [michalczapko](https://github.com/michalczapko) -* [wuhuwei](https://github.com/wuhuwei) +* Adam Rothman [adamrothman](https://github.com/adamrothman) +* Ben Asher [benasher44](https://github.com/benasher44) +* Andrew Martinez-Fonts [amartinezfonts](https://github.com/amartinezfonts) +* Wei Wu [wuhuwei](https://github.com/wuhuwei) +* Alex Levy [mesozoic](https://github.com/mesozoic) +* Anthony Sottile [asottile](https://github.com/asottile) +* Jenny Lemmnitz [jetze](https://github.com/jetze) +* Kurtis Freedland [KurtisFreedland](https://github.com/KurtisFreedland) +* MichaΕ‚ Czapko [michalczapko](https://github.com/michalczapko) +* Prayag Verma [pra85](https://github.com/pra85) +* Stephen Brennan [brenns10](https://github.com/brenns10) +* Wayne Crasta [waynecrasta](https://github.com/waynecrasta) +* Dennis Coldwell [dencold](https://github.com/dencold) +* Andrew Lau [ajlau](https://github.com/ajlau) +* Alina Rada [transcedentalia](https://github.com/transcedentalia) +* Matthew Bentley [matthewbentley](https://github.com/matthewbentley) +* Kevin Hock [KevinHock](https://github.com/KevinHock) +* Duncan Cook [theletterd](https://github.com/theletterd) +* Billy Montgomery [billyxs](https://github.com/billyxs) For more info check out the [Authors](AUTHORS.md) file. diff --git a/loveapp/config-example.py b/loveapp/config-example.py index 36fd889..21d0bca 100644 --- a/loveapp/config-example.py +++ b/loveapp/config-example.py @@ -53,3 +53,15 @@ CompanyValue('BE_EXCELLENT', 'Be excellent to each other', ('excellent', 'BeExcellent', 'WyldStallyns')), CompanyValue('DUST_IN_THE_WIND', 'All we are is dust in the wind, dude.', ('woah', 'whoa', 'DustInTheWind')) ] + +# Highlight a user's avatar when their message contains a word or phrase that matches one of the options. +# If a match is found, the emoji you provide will be added on the user's avatar. +MESSAGE_EMOTES = { + "happy workiversary": "πŸ₯³", + "happy birthday": "πŸŽ‰", +} + +# Temporaral separation of clustered loves. +# i.e., if two loves have the same content but are sent more than this time apart, +# they will be considered separate clusters. +LOVE_CLUSTERING_TIME_WINDOW_DAYS = 7 diff --git a/loveapp/logic/love.py b/loveapp/logic/love.py index b5db5a2..ad7f907 100644 --- a/loveapp/logic/love.py +++ b/loveapp/logic/love.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -from datetime import datetime +from collections import defaultdict +from datetime import datetime, timedelta from google.appengine.api import taskqueue @@ -27,8 +28,82 @@ def _love_query(start_dt, end_dt, include_secret): query = query.filter(Love.secret == False) # noqa return query +def cluster_loves_by_time(loves, time_window_days=None): + time_window_days = time_window_days or config.LOVE_CLUSTERING_TIME_WINDOW_DAYS + # Phase 1: group by content and secrecy + groups = defaultdict(list) + for love in loves: + # key is a combo of message and secret status, since secret loves + # shouldn't be grouped with non-secret ones + key = love.message.strip().lower() + f"_secret={love.secret}" + groups[key].append(love) + + # Phase 2: within each group, do temporal clustering + clustered_groups = [] + threshold = timedelta(days=time_window_days) + + for loves in groups.values(): + # Sort by timestamp + loves.sort(key=lambda l: l.timestamp) + current_cluster = [] + last_time = None + for love in loves: + if not current_cluster: + current_cluster.append(love) + last_time = love.timestamp + else: + if love.timestamp - last_time > threshold: + # Finish old cluster, start new one + clustered_groups.append(current_cluster) + current_cluster = [love] + else: + current_cluster.append(love) + last_time = love.timestamp + if current_cluster: + clustered_groups.append(current_cluster) + + # Annotate each cluster with its representative timestamp and collect unique senders and recipients + results = [] + for cluster in clustered_groups: + newest_time = max(love.seconds_since_epoch for love in cluster) + + # Use ordered data structures to preserve the order of appearance + unique_sender_keys = [] + unique_recipient_keys = [] + + # First pass - collect unique keys while preserving order + for love in cluster: + # Add sender key if not already in the list + if love.sender_key not in unique_sender_keys and love.sender_key: + unique_sender_keys.insert(0, love.sender_key) + + # Add recipient key if not already in the list + if love.recipient_key not in unique_recipient_keys and love.recipient_key: + unique_recipient_keys.insert(0, love.recipient_key) + + # Second pass - get the actual objects + senders = [key.get() for key in unique_sender_keys] + recipients = [key.get() for key in unique_recipient_keys] + + results.append({ + 'content': cluster[0].message, + 'emote': cluster[0].emote, + 'is_secret': cluster[0].secret, # Add is_secret flag to result + 'senders': senders, + 'recipients': recipients, + 'sender_count': len(senders), + 'recipient_count': len(recipients), + 'most_recent_love_timestamp': newest_time, + }) + + # Sort by most recent timestamp + results.sort(key=lambda x: x['most_recent_love_timestamp'], reverse=True) + # Return the sorted list of clusters + return results + def _sent_love_query(employee_key, start_dt, end_dt, include_secret): + return _love_query(start_dt, end_dt, include_secret).filter(Love.sender_key == employee_key) diff --git a/loveapp/models/love.py b/loveapp/models/love.py index eafa84a..a860723 100644 --- a/loveapp/models/love.py +++ b/loveapp/models/love.py @@ -5,6 +5,8 @@ from loveapp.models import Employee +import loveapp.config as config + class Love(ndb.Model): """Models an instance of sent love.""" @@ -18,3 +20,12 @@ class Love(ndb.Model): @property def seconds_since_epoch(self): return int(mktime(self.timestamp.timetuple())) + + @property + def emote(self) -> str | None: + message = self.message + for message_substring, emoji in config.MESSAGE_EMOTES.items(): + if message and message_substring.lower() in message.lower(): + return emoji + + return None \ No newline at end of file diff --git a/loveapp/themes/default/static/css/style.css b/loveapp/themes/default/static/css/style.css index a46e0cc..a1e2752 100644 --- a/loveapp/themes/default/static/css/style.css +++ b/loveapp/themes/default/static/css/style.css @@ -47,10 +47,18 @@ h2 { .love-message-container { margin-bottom: 20px; clear: left; + position: relative; } .love-message { margin-left: 60px; + background-color: #f8f8f8; + padding: 10px 20px; + border-radius: 5px; +} + +.user-list { + margin-top: 10px; } .love-byline { @@ -352,3 +360,248 @@ h4 { -2px 0px 0px #000, -2px -1px 0px #000; } + +.badge { + vertical-align: text-bottom; + border-radius: 5px;; +} + +.loves-columns { + column-count: 2; + column-gap: 2em; +} + +.love-message-container { + break-inside: avoid; + margin-bottom: 1.5em; + display: block; +} + +.loves-columns .love-message-container { + width: 100%; + box-sizing: border-box; +} + +.love-message-container .emote { + position: absolute; + top: -10px; left: 25px; + } + +a.love-plus-one, +a.love-plus-one:active { + outline: none; + background: none; + border: none; + padding: 0; + margin-left: 8px; + cursor: pointer; + font-weight: bold; +} + +.love-plus-one.disabled { + cursor: not-allowed; +} + +.love-plus-one .circle { + display: inline-block; + width: 20px; + height: 18px; + position: relative; + transform: rotate(-45deg); + margin: 2px 4px; + background: #ea050b; + color: white; + text-align: center; + font-size: 12px; + transition: transform 0.2s ease-in-out, background 0.2s ease-in-out; + transform: scale(1); +} +.love-plus-one .circle:before, +.love-plus-one .circle:after { + content: ''; + width: 18px; + height: 18px; + border-radius: 50%; + background: inherit; + position: absolute; + z-index: -1; +} +.love-plus-one .circle:before { + top: -10px; + left: 0; +} +.love-plus-one .circle:after { + top: 0; + right: -10px; +} + +/* Disabled state */ +.love-plus-one.disabled .circle { + background: #aaa; + cursor: default; + pointer-events: none; +} + +.love-plus-one:not(.disabled):hover .circle { + background: #c41200; + transition: transform 0.3s ease-in-out, background 0.3s ease-in-out; + transform: scale(1.1); +} + +.pagination { + display: flex; + justify-content: center; + gap: 2px; + align-items: center; + } + .pagination a, .pagination span { + padding: 6px 12px; + margin: 0 2px; + border-radius: 3px; + text-decoration: none; + color: #555; + background: #fff; + font-size: 1em; + } + .pagination a:hover { + background: #f0f0f0; + } + .pagination span.active { + background: #e00707; + color: #fff; + border-color: #e00707; + font-weight: bold; + pointer-events: none; + } + .pagination span.disabled { + color: #ccc; + border-color: #eee; + background: #f9f9f9; + cursor: not-allowed; + pointer-events: none; + } + + .user-list { + position: relative; + overflow: hidden; + max-height: 45px; + transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1); + margin-top: 7px; + padding-bottom: 2px; + padding-right: 36px; + background: #f8f8f8; + } + + .user-list.user-list-expanded { + max-height: 500px; + } + + .user-list-gradient { + pointer-events: none; + position: absolute; + left: 0; right: 0; bottom: 0; + height: 30px; + background: linear-gradient(to bottom, rgba(250,250,251,0), #f8f8f8); + transition: opacity 0.2s; + display: none; + } + .user-list.is-collapsible .user-list-gradient { display: block; } + .user-list.user-list-expanded .user-list-gradient { opacity: 0; } + + .user-list-expander { + position: absolute; + right: 0px; /* tweak as needed */ + bottom: 8px; + z-index: 2; + background: rgba(255,255,255,0.98); + border: 1px solid #eee; + padding: 2px 8px; + border-radius: 12px; + cursor: pointer; + box-shadow: 0 0 4px rgba(0,0,0,0.03); + transition: background 0.2s, box-shadow 0.2s; + display: none; /* default; toggled by JS (see below) */ + } + + .user-list.is-collapsible .user-list-expander { display: inline-block; } + + .user-list.user-list-expanded .user-list-expander .chevron { + transform: rotate(225deg); + } + + .user-list-expander .chevron { + display: inline-block; + width: 0.4em; + height: 0.4em; + vertical-align: middle; + border-right: 2px solid #888; + border-bottom: 2px solid #888; + padding: 3px; + margin: 0; + /* default: down */ + transform: rotate(45deg); + transition: transform 0.2s; + } + + /* visually hidden text for screen readers */ + .sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); border: 0; + } + + .love-plus-one-container { + position: relative; + float: right; + margin: -15px -15px 0 0; + } + + + .love-plus-one .custom-tooltip { + visibility: hidden; + width: 140px; + background-color: #222; + color: #fff; + text-align: center; + border-radius: 0.25rem; + padding: 0.5em; + position: absolute; + z-index: 1000; + /* Position from the right side */ + top: 50%; /* vertically center */ + left: 100%; /* to the right of the element */ + transform: translateY(-50%); /* perfect vertical centering */ + margin-left: 2em; /* increased spacing to move tooltip further right */ + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + font-size: 0.5em; + box-shadow: 0px 3px 12px rgba(0,0,0,0.15); + } + + .love-plus-one .custom-tooltip::after { + content: ''; + position: absolute; + top: 50%; + right: 100%; /* at the left edge of tooltip */ + transform: translateY(-50%); + border-width: 6px; + border-style: solid; + border-color: transparent #222 transparent transparent; /* arrow pointing left */ + } + + .love-plus-one:hover .custom-tooltip, + .love-plus-one:focus .custom-tooltip { + visibility: visible; + opacity: 1; + pointer-events: auto; + } + + /* Optionally: show "not-allowed" cursor for disabled state */ + .love-plus-one.disabled { + cursor: not-allowed; + pointer-events: auto; + } + diff --git a/loveapp/themes/default/static/js/main.js b/loveapp/themes/default/static/js/main.js index e0a332b..5e68fca 100644 --- a/loveapp/themes/default/static/js/main.js +++ b/loveapp/themes/default/static/js/main.js @@ -24,7 +24,218 @@ function setupLinkify() { $('.love-message').linkify(); } +/** + * Initialize autocomplete and other functionality for the love form + */ +function initLoveForm() { + if (!$('#send-love-form').length) { + return; // Exit if we're not on a page with the love form + } + + $('#nav-send').addClass('active'); + $('#secret-love-label').tooltip(); + $('input[name="recipients"]').focus(); + if ($('input[name="recipients"]').val() != '') { + var messageText = $('textarea[name="message"]').val(); + $('textarea[name="message"]').focus().val('').val(messageText); + } + $('#love-error').hide(); + + // Hashtags autocomplete - code heavily sampled from http://jqueryui.com/autocomplete/#multiple-remote + $('textarea[name="message"]') + .bind('keydown', function(event) { + if (event.keyCode === $.ui.keyCode.TAB && + $(this).data("ui-autocomplete").menu.active) { + event.preventDefault(); + } + }) + .autocomplete({ + source: function(request, response) { + $.getJSON('/values/autocomplete', { + term: request.term.split(/\s/).pop() + }, response); + }, + search: function() { + var term = this.value.split(/\s/).pop(); + if (!term.startsWith("#")) { + return false; + } + return term; + }, + focus: function() { return false; }, + select: function(event, ui) { + var terms = this.value.split(/\s/); + terms.pop(); + terms.push(ui.item.value + " "); + this.value = terms.join(" "); + return false; + }, + minLength: 1, + delay: 5, + autoFocus: true + }); + + // Recipients autocomplete + $('input[name="recipients"]') + // don't navigate away from the field on tab when selecting an item + .bind('keydown', function(event) { + if (event.keyCode === $.ui.keyCode.TAB && + $(this).autocomplete().menu.active) { + event.preventDefault(); + } + }) + .autocomplete({ + source: function(request, response) { + $.getJSON('/user/autocomplete', { + term: extractLast(request.term) + }, response); + }, + search: function() { + // set minLength before attempting autocomplete + var term = extractLast(this.value); + if (term.length < 2) { + return false; + } + }, + focus: function() { + return false; + }, + select: function(event, ui) { + var terms = split(this.value); + // remove the current input + terms.pop(); + // add the selected item + terms.push(ui.item.value); + // add placeholder to get the comma-and-space at the end + terms.push(''); + this.value = terms.join(', '); + return false; + }, + }).data('ui-autocomplete')._renderItem = function(ul, item) { + return $("
  • ") + .attr('class', 'ui-menu-item avatar-autocomplete') + .attr('role', 'presentation') + .attr("data-value", item.value) + .append( + $("") + .attr('class', 'ui-corner-all') + .attr('tabindex', '-1') + .append( + '' + + item.label + + '') + ) + .appendTo(ul); + }; + + // Set up shareable link functionality + var loveLinkBlock = $('.love-link-block'); + if (loveLinkBlock.length) { + $('.create-link-btn').hide(); + hideLoveLinkBlockOnInputChange(loveLinkBlock); + setCopyToClipboardBtnAction(); + } +} + +// Helper functions for the love form +function split(val) { + return val.split(/,\s*/); +} + +function extractLast(term) { + return split(term).pop(); +} + +function hideLoveLinkBlockOnInputChange(loveLinkBlock) { + $('input[name="recipients"], textarea[name="message"]').change(function() { + $('input[name="recipients"], textarea[name="message"]').off('change'); + loveLinkBlock.hide(); + $('.create-link-btn').show(); + }); +} + +function setCopyToClipboardBtnAction() { + var copyBtn = document.querySelector('.copybtn'); + copyBtn.addEventListener('click', function() { + window.getSelection().removeAllRanges(); + var linkText = document.querySelector('.love-link'); + var range = document.createRange(); + range.selectNode(linkText); + window.getSelection().addRange(range); + document.execCommand('copy'); + window.getSelection().removeAllRanges(); + }); +} + +function togglePlusOneButton(element) { + const container = element.closest('.love-plus-one-container'); + const plusOneBtn = container.querySelector('.love-plus-one'); + const confirmForm = container.querySelector('.love-plus-one-confirm'); + + if (confirmForm.style.display === 'none') { + plusOneBtn.style.display = 'none'; + confirmForm.style.display = 'inline-block'; + } else { + plusOneBtn.style.display = 'inline-block'; + confirmForm.style.display = 'none'; + } +} +function checkUserListCollapsible(userList) { + if (!userList) return; + const badges = Array.from(userList.querySelectorAll('.badge')); + const expander = userList.querySelector('.user-list-expander'); + const gradient = userList.querySelector('.user-list-gradient'); + if (badges.length < 2) { + userList.classList.remove('is-collapsible', 'user-list-expanded'); + if (expander) expander.style.display = "none"; + if (gradient) gradient.style.display = "none"; + return; + } + const firstTop = badges[0].offsetTop; + const wraps = badges.some(badge => badge.offsetTop > firstTop); + if (wraps) { + userList.classList.add('is-collapsible'); + if (expander) expander.style.display = "inline-block"; + if (gradient) gradient.style.display = "block"; + } else { + userList.classList.remove('is-collapsible', 'user-list-expanded'); + if (expander) expander.style.display = "none"; + if (gradient) gradient.style.display = "none"; + } +} + +function toggleUserList(expanderBtn) { + const userList = expanderBtn.closest('.user-list'); + const expanded = userList.classList.toggle('user-list-expanded'); + if (expanded) { + expanderBtn.classList.add('chevron-rotated'); + expanderBtn.setAttribute('aria-expanded', 'true'); + userList.style.maxHeight = ''; // reset for measuring actual height + let fullHeight = userList.scrollHeight; + userList.style.maxHeight = fullHeight + "px"; // animate open! + } else { + expanderBtn.classList.remove('chevron-rotated'); + expanderBtn.setAttribute('aria-expanded', 'false'); + userList.style.maxHeight = ''; // REMOVE so it falls back to CSS (collapsed) height + } +} + +window.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.user-list').forEach(checkUserListCollapsible); +}); +window.addEventListener('resize', function() { + document.querySelectorAll('.user-list').forEach(checkUserListCollapsible); +}); + +var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) +tooltipTriggerList.forEach(function (tooltipTriggerEl) { + new bootstrap.Tooltip(tooltipTriggerEl) +}) + $(document).ready(function () { setupDateFormatting(); setupLinkify(); + initLoveForm(); // Add the initialization of the love form }); diff --git a/loveapp/themes/default/templates/explore.html b/loveapp/themes/default/templates/explore.html index a269cd2..0967ed2 100644 --- a/loveapp/themes/default/templates/explore.html +++ b/loveapp/themes/default/templates/explore.html @@ -1,6 +1,6 @@ {% extends theme("layout.html") %} -{% import theme("parts/love_message.html") as love_message %} +{% import theme("parts/grouped_love_message.html") as grouped_love_message %} " {% import theme("parts/photobox.html") as photobox %} {% import theme("parts/flash.html") as flash %} @@ -36,20 +36,19 @@

    {{ user.full_name }}

    Love Sent

    - {% if sent_loves %} + {% if grouped_sent_loves %}
    - {% for love in sent_loves %} - {% set sender = love.sender_key.get() %} - {% set recipient = love.recipient_key.get() %} - {{ love_message.love( - sender.first_name, - sender.username, - recipient.full_name, - recipient.username, - recipient, - love.message, - love.seconds_since_epoch) - }} + {% for grouped_love in grouped_sent_loves %} + {{ grouped_love_message.love( + "To", + current_user, + grouped_love.senders, + grouped_love.recipients, + grouped_love.content, + grouped_love.most_recent_love_timestamp, + grouped_love.is_secret, + grouped_love.emote + )}} {% endfor %}
    {% else %} @@ -60,20 +59,19 @@

    Love Sent

    Love Received

    - {% if received_loves %} + {% if grouped_received_loves %}
    - {% for love in received_loves %} - {% set sender = love.sender_key.get() %} - {% set recipient = love.recipient_key.get() %} - {{ love_message.love( - sender.full_name, - sender.username, - recipient.first_name, - recipient.username, - sender, - love.message, - love.seconds_since_epoch) - }} + {% for grouped_love in grouped_received_loves %} + {{ grouped_love_message.love( + "From", + current_user, + grouped_love.senders, + grouped_love.recipients, + grouped_love.content, + grouped_love.most_recent_love_timestamp, + grouped_love.is_secret, + grouped_love.emote + )}} {% endfor %}
    {% else %} diff --git a/loveapp/themes/default/templates/home.html b/loveapp/themes/default/templates/home.html index 22af9df..6b42522 100644 --- a/loveapp/themes/default/templates/home.html +++ b/loveapp/themes/default/templates/home.html @@ -7,138 +7,4 @@ {% endblock %} {% block javascript %} - {% endblock %} diff --git a/loveapp/themes/default/templates/keys.html b/loveapp/themes/default/templates/keys.html index 9f64d96..4260d5d 100644 --- a/loveapp/themes/default/templates/keys.html +++ b/loveapp/themes/default/templates/keys.html @@ -1,6 +1,5 @@ {% extends theme("layout.html") %} -{% import theme("parts/love_message.html") as love_message %} {% import theme("parts/flash.html") as flash %} {% block title %}API Keys{% endblock %} diff --git a/loveapp/themes/default/templates/me.html b/loveapp/themes/default/templates/me.html index 65a1c8c..335d5dc 100644 --- a/loveapp/themes/default/templates/me.html +++ b/loveapp/themes/default/templates/me.html @@ -1,6 +1,6 @@ {% extends theme("layout.html") %} -{% import theme("parts/love_message.html") as love_message %} +{% import theme("parts/grouped_love_message.html") as grouped_love_message %} {% block title %}My Love{% endblock %} @@ -8,21 +8,19 @@

    Love Received

    - {% if received_loves %} + {% if grouped_received_loves %}
    - {% for love in received_loves %} - {% set sender = love.sender_key.get() %} - {% set recipient = love.recipient_key.get() %} - {{ love_message.love( - sender.full_name, - sender.username, - 'you', - '', - sender, - love.message, - love.seconds_since_epoch, - love.secret) - }} + {% for grouped_love in grouped_received_loves %} + {{ grouped_love_message.love( + "From", + current_user, + grouped_love.senders, + grouped_love.recipients, + grouped_love.content, + grouped_love.most_recent_love_timestamp, + grouped_love.is_secret, + grouped_love.emote + )}} {% endfor %}
    {% else %} @@ -35,22 +33,20 @@

    Love Received

    Love Sent

    - {% if sent_loves %} + {% if grouped_sent_loves %}
    - {% for love in sent_loves %} - {% set sender = love.sender_key.get() %} - {% set recipient = love.recipient_key.get() %} - {{ love_message.love( - 'You', - '', - recipient.full_name, - recipient.username, - recipient, - love.message, - love.seconds_since_epoch, - love.secret) - }} - {% endfor %} + {% for grouped_love in grouped_sent_loves %} + {{ grouped_love_message.love( + "To", + current_user, + grouped_love.senders, + grouped_love.recipients, + grouped_love.content, + grouped_love.most_recent_love_timestamp, + grouped_love.is_secret, + grouped_love.emote + )}} + {% endfor %}
    {% else %}
    @@ -59,6 +55,34 @@

    Love Sent

    {% endif %}
    + +
    + + +
    {% endblock %} {% block javascript %} diff --git a/loveapp/themes/default/templates/parts/grouped_love_message.html b/loveapp/themes/default/templates/parts/grouped_love_message.html new file mode 100644 index 0000000..55c98ee --- /dev/null +++ b/loveapp/themes/default/templates/parts/grouped_love_message.html @@ -0,0 +1,84 @@ +{% import theme("parts/photobox.html") as photobox %} + +{% macro love(to_from_flag, current_user, senders, recipients, message, ts, secret, emote) %} +
    + {% if to_from_flag == "To" %} + {% set user_list = recipients %} + {% else %} + {% set user_list = senders %} + {% endif %} + + {% set first_user = user_list[0] %} + {{ photobox.user_icon(first_user) }} + {% if emote %} +
    {{ emote }}
    + {% endif %} + + + +
    + {# + Checks if the current user's username is not present in the list of senders' usernames and they are not the only recipient. + If this is the case, disable the plus one button. + #} + {% set already_sent_this_love = current_user.username in senders | map(attribute='username') | list %} + {% set already_received_this_love = (recipients|length == 1 and recipients[0].username == current_user.username) %} + {% set tooltip_text = '' %} + {% if already_sent_this_love %} + {% set tooltip_text = "You already sent this!" %} + {% elif already_received_this_love %} + {% set tooltip_text = "You can't send love to yourself!" %} + {% else %} + {% set tooltip_text = "Add your love!" %} + {% endif %} + {% set disable_plus_one = already_sent_this_love or already_received_this_love %} + + +1 + {{ tooltip_text }} + +
    + +
    + {{ message|linkify_company_values }} + + {% if user_list|length > 1 %} +
    + {% for user in user_list %} + {{ user.full_name }} + {% endfor %} + +
    +
    + {% endif %} +
    +
    + +{% endmacro %} diff --git a/loveapp/themes/default/templates/parts/love_message.html b/loveapp/themes/default/templates/parts/love_message.html index a19b28f..2653614 100644 --- a/loveapp/themes/default/templates/parts/love_message.html +++ b/loveapp/themes/default/templates/parts/love_message.html @@ -16,6 +16,37 @@ loved {% if display_recipient_link %}{% endif %}{{ recipient_name }} {{ ts * 1000 }} +
    + + +
    +
    {% endmacro %} diff --git a/loveapp/themes/default/templates/values.html b/loveapp/themes/default/templates/values.html index b6ffc99..04b4915 100644 --- a/loveapp/themes/default/templates/values.html +++ b/loveapp/themes/default/templates/values.html @@ -1,6 +1,7 @@ {% extends theme("layout.html") %} -{% import theme("parts/love_message.html") as love_message %} +{% import theme("parts/grouped_love_message.html") as grouped_love_message %} + {% block title %}Love with company values{% endblock %} @@ -20,38 +21,20 @@

    Who's repping the Values?

    - {% if loves_first_list %} -
    -
    - {% for love in loves_first_list %} - {% set sender = love.sender_key.get() %} - {% set recipient = love.recipient_key.get() %} - {{ love_message.love( - sender.full_name, - sender.username, - recipient.first_name, - recipient.username, - sender, - love.message, - love.seconds_since_epoch) - }} - {% endfor %} -
    -
    -
    - {% for love in loves_second_list %} - {% set sender = love.sender_key.get() %} - {% set recipient = love.recipient_key.get() %} - {{ love_message.love( - sender.full_name, - sender.username, - recipient.first_name, - recipient.username, - sender, - love.message, - love.seconds_since_epoch) - }} - {% endfor %} + {% if grouped_loves %} +
    +
    + {% for grouped_love in grouped_loves %} + {{ grouped_love_message.love( + "To", + current_user, + grouped_love.senders, + grouped_love.recipients, + grouped_love.content, + grouped_love.most_recent_love_timestamp, + grouped_love.is_secret + )}} + {% endfor %}
    {% else %} diff --git a/loveapp/util/formatting.py b/loveapp/util/formatting.py deleted file mode 100644 index fad52fa..0000000 --- a/loveapp/util/formatting.py +++ /dev/null @@ -1,12 +0,0 @@ -def format_loves(loves): - # organise loves into two roughly equal lists for displaying - if len(loves) < 20: - loves_list_one = loves - loves_list_two = [] - else: - loves_list_one = loves[:len(loves)//2] - loves_list_two = loves[len(loves)//2:] - - if len(loves_list_one) < len(loves_list_two): - loves_list_one.append(loves_list_two.pop()) - return loves_list_one, loves_list_two diff --git a/loveapp/views/web.py b/loveapp/views/web.py index d4e5d11..c596cdd 100644 --- a/loveapp/views/web.py +++ b/loveapp/views/web.py @@ -15,7 +15,6 @@ import loveapp.logic.employee import loveapp.logic.event import loveapp.logic.love -import loveapp.logic.love_count import loveapp.logic.love_link import loveapp.logic.subscription from errors import NoSuchEmployee @@ -36,7 +35,6 @@ from loveapp.util.decorators import admin_required from loveapp.util.decorators import csrf_protect from loveapp.util.decorators import user_required -from loveapp.util.formatting import format_loves from loveapp.util.recipient import sanitize_recipients from loveapp.util.render import make_json_response from loveapp.util.render import render_template @@ -44,6 +42,9 @@ web_app = Blueprint('web_app', __name__) +# Constants +DEFAULT_PAGE_SIZE = 20 + @web_app.route('/', methods=['GET']) @user_required @@ -65,18 +66,48 @@ def home(): @web_app.route('/me', methods=['GET']) @user_required def me(): - current_employee = Employee.get_current_employee() + # Get pagination params + try: + page = int(request.args.get('page', 1)) + requested_page_size = int(request.args.get('page_size', DEFAULT_PAGE_SIZE)) + page_size = min(DEFAULT_PAGE_SIZE, requested_page_size) # Cap at DEFAULT_PAGE_SIZE + except ValueError: + page = 1 + page_size = DEFAULT_PAGE_SIZE + requested_page_size = DEFAULT_PAGE_SIZE + love_lookback_limit = 5000 - sent_love = loveapp.logic.love.recent_sent_love(current_employee.key, limit=20) - received_love = loveapp.logic.love.recent_received_love(current_employee.key, limit=20) + current_employee = Employee.get_current_employee() - return render_template( - 'me.html', - current_time=datetime.utcnow(), - current_user=current_employee, - sent_loves=sent_love.get_result(), - received_loves=received_love.get_result() - ) + sent_love = loveapp.logic.love.recent_sent_love(current_employee.key, limit=love_lookback_limit).get_result() + grouped_sent_love = loveapp.logic.love.cluster_loves_by_time(sent_love) + received_love = loveapp.logic.love.recent_received_love(current_employee.key, limit=love_lookback_limit).get_result() + grouped_received_love = loveapp.logic.love.cluster_loves_by_time(received_love) + + total_items = max(len(grouped_sent_love), len(grouped_received_love)) + total_pages = (total_items + page_size - 1) // page_size # Calculate total pages + + # Calculate paged results + page = max(1, min(page, total_pages)) if total_pages > 0 else 1 + start = (page - 1) * page_size + end = start + page_size + grouped_sent_love_page = grouped_sent_love[start:end] + grouped_received_love_page = grouped_received_love[start:end] + + template_args = { + 'current_time': datetime.utcnow(), + 'current_user': current_employee, + 'grouped_sent_loves': grouped_sent_love_page, + 'grouped_received_loves': grouped_received_love_page, + 'page': page, + 'total_pages': total_pages, + } + + # Only include page_size in template if it's different from the default + if requested_page_size != DEFAULT_PAGE_SIZE: + template_args['page_size'] = page_size + + return render_template('me.html', **template_args) @web_app.route('/', methods=['GET']) @@ -105,15 +136,14 @@ def single_company_value(company_value_id): current_employee = Employee.get_current_employee() - loves = loveapp.logic.love.recent_loves_by_company_value(None, company_value.id, limit=100).get_result() - loves_list_one, loves_list_two = format_loves(loves) + loves = loveapp.logic.love.recent_loves_by_company_value(None, company_value.id, limit=1000).get_result() + grouped_loves = loveapp.logic.love.cluster_loves_by_time(loves)[:100] return render_template( 'values.html', current_time=datetime.utcnow(), current_user=current_employee, - loves_first_list=loves_list_one, - loves_second_list=loves_list_two, + grouped_loves=grouped_loves, values=get_company_value_link_pairs(), company_value_string=company_value.display_string ) @@ -125,8 +155,10 @@ def company_values(): if not config.COMPANY_VALUES: abort(404) - loves = loveapp.logic.love.recent_loves_with_any_company_value(None, limit=100).get_result() - loves_list_one, loves_list_two = format_loves(loves) + page_size = 100 + love_lookback_limit = 1000 + loves = loveapp.logic.love.recent_loves_with_any_company_value(None, limit=love_lookback_limit).get_result() + grouped_loves = loveapp.logic.love.cluster_loves_by_time(loves)[:page_size] current_employee = Employee.get_current_employee() @@ -134,8 +166,7 @@ def company_values(): 'values.html', current_time=datetime.utcnow(), current_user=current_employee, - loves_first_list=loves_list_one, - loves_second_list=loves_list_two, + grouped_loves=grouped_loves, values=get_company_value_link_pairs(), company_value_string=None ) @@ -192,15 +223,21 @@ def explore(): flash('Sorry, "{}" is not a valid user.'.format(username), 'error') return redirect(url_for('web_app.explore')) - sent_love = loveapp.logic.love.recent_sent_love(user_key, include_secret=False, limit=20) - received_love = loveapp.logic.love.recent_received_love(user_key, include_secret=False, limit=20) + page_size = 20 + love_lookback_limit = 1000 + sent_love = loveapp.logic.love.recent_sent_love(user_key, limit=love_lookback_limit, include_secret=False).get_result() + grouped_sent_love = loveapp.logic.love.cluster_loves_by_time(sent_love)[:page_size] + received_love = loveapp.logic.love.recent_received_love(user_key, limit=love_lookback_limit, include_secret=False).get_result() + grouped_received_love = loveapp.logic.love.cluster_loves_by_time(received_love)[:page_size] + current_user = Employee.get_current_employee() return render_template( 'explore.html', current_time=datetime.utcnow(), - sent_loves=sent_love.get_result(), - received_loves=received_love.get_result(), - user=user_key.get() + grouped_received_loves=grouped_received_love, + grouped_sent_loves=grouped_sent_love, + user=user_key.get(), + current_user=current_user, ) diff --git a/tests/logic/cluster_loves_test.py b/tests/logic/cluster_loves_test.py new file mode 100644 index 0000000..9774246 --- /dev/null +++ b/tests/logic/cluster_loves_test.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +import unittest +from datetime import datetime, timedelta + +import mock +import pytest + +from loveapp.logic.love import cluster_loves_by_time +from loveapp.models import Love +from testing.factories import create_employee + + +class MockLove: + """A mock Love object for testing cluster_loves_by_time.""" + def __init__(self, message, timestamp, secret=False, sender_key=None, recipient_key=None): + self.message = message + self.timestamp = timestamp + self.seconds_since_epoch = int(timestamp.timestamp()) + self.secret = secret + self.sender_key = sender_key + self.recipient_key = recipient_key + self.emote = None # Not used in this test, but included for completeness + + +@pytest.mark.usefixtures('gae_testbed') +class TestClusterLoves(unittest.TestCase): + def setUp(self): + self.alice = create_employee(username='alice') + self.bob = create_employee(username='bob') + self.carol = create_employee(username='carol') + self.dave = create_employee(username='dave') + + # Base timestamp for creating love objects + self.base_time = datetime.now() + + def test_basic_content_clustering(self): + """Test that loves with identical content are clustered together.""" + # Create loves with identical content but different timestamps + loves = [ + MockLove("Great job!", self.base_time, sender_key=self.alice.key, recipient_key=self.bob.key), + MockLove("Great job!", self.base_time + timedelta(minutes=30), sender_key=self.carol.key, recipient_key=self.dave.key), + ] + + clusters = cluster_loves_by_time(loves) + + # Should have one cluster with both loves + self.assertEqual(len(clusters), 1) + self.assertEqual(clusters[0]['content'], "Great job!") + self.assertEqual(clusters[0]['sender_count'], 2) + self.assertEqual(clusters[0]['recipient_count'], 2) + + def test_secret_status_separation(self): + """Test that loves are separated by secret status.""" + + # Create loves with identical content but different secret status + loves = [ + MockLove("Great job!", self.base_time, secret=False, sender_key=self.alice.key, recipient_key=self.bob.key), + MockLove("Great job!", self.base_time + timedelta(minutes=30), secret=True, sender_key=self.carol.key, recipient_key=self.dave.key), + ] + + clusters = cluster_loves_by_time(loves) + + # Should have two clusters, one secret and one not + self.assertEqual(len(clusters), 2) + # Check that we have one secret and one non-secret cluster + secret_clusters = [c for c in clusters if c['is_secret']] + non_secret_clusters = [c for c in clusters if not c['is_secret']] + self.assertEqual(len(secret_clusters), 1) + self.assertEqual(len(non_secret_clusters), 1) + + def test_temporal_clustering(self): + """Test that loves are clustered by time window.""" + # Create loves with identical content but timestamps outside the default window + loves = [ + MockLove("Great job!", self.base_time, sender_key=self.alice.key, recipient_key=self.bob.key), + # This one is 2 days later, should be in a separate cluster with default window of 1 day + MockLove("Great job!", self.base_time + timedelta(days=2), sender_key=self.carol.key, recipient_key=self.dave.key), + ] + + clusters = cluster_loves_by_time(loves) + + # Should have two clusters due to time separation + self.assertEqual(len(clusters), 2) + + # Test with custom time window that would include both + clusters_with_larger_window = cluster_loves_by_time(loves, time_window_days=3) + self.assertEqual(len(clusters_with_larger_window), 1) + + def test_sender_recipient_aggregation(self): + """Test that unique senders and recipients are properly aggregated.""" + # Create loves with same content but overlapping senders and recipients + loves = [ + # Alice loves Bob + MockLove("Great team!", self.base_time, sender_key=self.alice.key, recipient_key=self.bob.key), + # Alice loves Carol too + MockLove("Great team!", self.base_time + timedelta(minutes=10), sender_key=self.alice.key, recipient_key=self.carol.key), + # Dave also loves Bob + MockLove("Great team!", self.base_time + timedelta(minutes=20), sender_key=self.dave.key, recipient_key=self.bob.key), + ] + + clusters = cluster_loves_by_time(loves) + + # Should have one cluster with 2 unique senders and 2 unique recipients + self.assertEqual(len(clusters), 1) + self.assertEqual(clusters[0]['sender_count'], 2) # Alice and Dave + self.assertEqual(clusters[0]['recipient_count'], 2) # Bob and Carol + + def test_timestamp_sorting(self): + """Test that clusters are sorted by most recent timestamp.""" + # Create different loves with different timestamps + loves = [ + # Older message + MockLove("Good work!", self.base_time - timedelta(days=1), sender_key=self.alice.key, recipient_key=self.bob.key), + # Newer message + MockLove("Great job!", self.base_time, sender_key=self.carol.key, recipient_key=self.dave.key), + ] + + clusters = cluster_loves_by_time(loves) + + # Should have two clusters, with the newer one first + self.assertEqual(len(clusters), 2) + self.assertEqual(clusters[0]['content'], "Great job!") + self.assertEqual(clusters[1]['content'], "Good work!") + + def test_complex_clustering(self): + """Test a complex scenario with multiple clustering criteria.""" + # Create various loves with different properties + loves = [ + + # Same content but 2 days later (separate cluster) + MockLove("Great job!", self.base_time + timedelta(days=2), sender_key=self.alice.key, recipient_key=self.carol.key), + + # Same content but secret + MockLove("Great job!", self.base_time + timedelta(hours=3), secret=True, sender_key=self.dave.key, recipient_key=self.alice.key), + + # Same content, same day + MockLove("Great job!", self.base_time, sender_key=self.alice.key, recipient_key=self.bob.key), + MockLove("Great job!", self.base_time + timedelta(hours=2), sender_key=self.carol.key, recipient_key=self.dave.key), + + # Different content + MockLove("Nice presentation!", self.base_time + timedelta(hours=1), sender_key=self.bob.key, recipient_key=self.alice.key) + + ] + + clusters = cluster_loves_by_time(loves) + + # pretty sure this is wrong. + # Should have 4 clusters: + # 1. "Great job!" (non-secret, recent) + # 2. "Great job!" (secret) + # 3. "Nice presentation!" + # 4. "Great job!" (non-secret, older) + self.assertEqual(len(clusters), 4) + + # Verify they're in the right order (most recent first) + self.assertEqual(clusters[0]['content'], "Great job!") + self.assertFalse(clusters[0]['is_secret']) # The recent non-secret one + self.assertEqual(clusters[0]['sender_count'], 1) # Alice + + self.assertEqual(clusters[1]['content'], "Great job!") + self.assertTrue(clusters[1]['is_secret']) + self.assertEqual(clusters[1]['sender_count'], 1) # Dave + + self.assertEqual(clusters[2]['content'], "Great job!") # The older one + self.assertFalse(clusters[2]['is_secret']) + self.assertEqual(clusters[2]['sender_count'], 2) # Alice and Carol + + self.assertEqual(clusters[3]['content'], "Nice presentation!") + self.assertFalse(clusters[3]['is_secret']) + self.assertEqual(clusters[3]['sender_count'], 1) # Bob + + + + diff --git a/tests/models/love_test.py b/tests/models/love_test.py new file mode 100644 index 0000000..a37c181 --- /dev/null +++ b/tests/models/love_test.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +from datetime import datetime +from time import mktime + +import mock +import pytest +from google.appengine.ext import ndb + +from loveapp.models import Love +from testing.factories import create_employee, create_love + + +def test_create_love(gae_testbed): + """Test creating a basic Love instance""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message="Great job on the project!", + secret=False + ) + + assert love.sender_key == sender.key + assert love.recipient_key == recipient.key + assert love.message == "Great job on the project!" + assert not love.secret + assert isinstance(love.timestamp, datetime) + assert love.company_values == [] + + +def test_seconds_since_epoch(gae_testbed): + """Test the seconds_since_epoch property""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message="Test message" + ) + + expected_seconds = int(mktime(love.timestamp.timetuple())) + assert love.seconds_since_epoch == expected_seconds + + +@mock.patch('loveapp.models.love.config') +def test_emote_with_matching_message(mock_love_config, gae_testbed): + """Test emote property returns correct emoji when message matches""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + + mock_love_config.MESSAGE_EMOTES = { + 'great': 'πŸ‘', + 'awesome': 'πŸŽ‰' + } + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message="You did a GREAT job!" + ) + + assert love.emote == 'πŸ‘' + + +@mock.patch('loveapp.models.love.config') +def test_emote_with_no_matching_message(mock_love_config, gae_testbed): + """Test emote property returns None when no message matches""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + + mock_love_config.MESSAGE_EMOTES = { + 'great': 'πŸ‘', + 'awesome': 'πŸŽ‰' + } + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message="You did a good job!" + ) + + assert love.emote is None + + +def test_emote_with_none_message(gae_testbed): + """Test emote property handles None message""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message=None + ) + + assert love.emote is None + + +def test_love_with_company_values(gae_testbed): + """Test creating Love with company values""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + company_values = ['Integrity', 'Innovation'] + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message="Great demonstration of our values!", + company_values=company_values + ) + + assert love.company_values == company_values + + +def test_secret_love_default_value(gae_testbed): + """Test that secret defaults to False""" + sender = create_employee(username='sender') + recipient = create_employee(username='recipient') + + love = create_love( + sender_key=sender.key, + recipient_key=recipient.key, + message="Test message" + ) + + assert not love.secret \ No newline at end of file diff --git a/tests/util/formatting_test.py b/tests/util/formatting_test.py deleted file mode 100644 index 1b11c41..0000000 --- a/tests/util/formatting_test.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from loveapp.util.formatting import format_loves - - -@pytest.mark.parametrize('loves, expected', [ - ([], ([], [])), - (list(range(5)), (list(range(5)), [])), - (list(range(31)), (list(range(15)) + [30], list(range(15, 30)))), -]) -def test_format_loves(loves, expected): - assert format_loves(loves) == expected diff --git a/tests/views/web_test.py b/tests/views/web_test.py index e9fe00d..d10ceea 100644 --- a/tests/views/web_test.py +++ b/tests/views/web_test.py @@ -2,6 +2,7 @@ import mock import pytest from bs4 import BeautifulSoup +from datetime import datetime import loveapp.logic from loveapp.config import CompanyValue @@ -279,9 +280,9 @@ def test_me(self, client, recorded_templates): assert response_context['current_time'] is not None assert response_context['current_user'] == self.logged_in_employee - assert response_context['sent_loves'] == [] + assert response_context['grouped_sent_loves'] == [] assert 'Give and ye shall receive!' in response.data.decode() - assert response_context['received_loves'] == [] + assert response_context['grouped_received_loves'] == [] assert 'You haven\'t sent any love yet.' in response.data.decode() def test_me_with_loves(self, client, recorded_templates): @@ -299,13 +300,94 @@ def test_me_with_loves(self, client, recorded_templates): response = client.get('/me') _, response_context = recorded_templates[0] - assert response_context['sent_loves'] == [sent_love] + assert response_context['grouped_sent_loves'][0]["content"] == sent_love.message + assert response_context['grouped_received_loves'][0]["content"] == received_love.message + assert 'Well done.' in response.data.decode() - assert response_context['received_loves'] == [received_love] assert 'Awesome work.' in response.data.decode() dude.key.delete() + @mock.patch('loveapp.logic.love.recent_sent_love') + @mock.patch('loveapp.logic.love.recent_received_love') + def test_me_pagination(self, mock_recent_received_love, mock_recent_sent_love, client): + # Create mock Future objects with our test data + mock_future_sent = mock.Mock() + mock_future_received = mock.Mock() + + # Create real loves for our test + dude = create_employee(username='dude') + + # Create multiple love objects for pagination testing + sent_loves = [] + received_loves = [] + + for i in range(5): + sent_loves.append(create_love( + sender_key=self.logged_in_employee.key, + recipient_key=dude.key, + message=f'Sent love {i}' + )) + + received_loves.append(create_love( + sender_key=dude.key, + recipient_key=self.logged_in_employee.key, + message=f'Received love {i}' + )) + + # Set up our mocks + mock_future_sent.get_result = mock.Mock(return_value=sent_loves) + mock_future_received.get_result = mock.Mock(return_value=received_loves) + + mock_recent_sent_love.return_value = mock_future_sent + mock_recent_received_love.return_value = mock_future_received + + # Test first page (default) with page_size=2 + response = client.get('/me', query_string={'page_size': '2'}) + html = response.data.decode() + assert 'class="active">1<' in html + assert 'Sent love 0' in html + assert 'Sent love 1' in html + # Check that page 3 items aren't on page 1 + assert 'Sent love 4' not in html + + # Test explicit page 2 + response = client.get('/me', query_string={'page': '2', 'page_size': '2'}) + html = response.data.decode() + assert 'class="active">2<' in html + assert 'Sent love 2' in html + assert 'Sent love 3' in html + # Check that page 1 items aren't on page 2 + assert 'Sent love 0' not in html + assert 'Sent love 1' not in html + + # Test last page + response = client.get('/me', query_string={'page': '3', 'page_size': '2'}) + html = response.data.decode() + assert 'class="active">3<' in html + assert 'Sent love 4' in html + # Check that page 1 and 2 items aren't on page 3 + assert 'Sent love 0' not in html + assert 'Sent love 2' not in html + + # Clean up + for love in sent_loves + received_loves: + love.key.delete() + dude.key.delete() + + def test_me_pagination_bounds(self, client): + # Test page number below valid range + response = client.get('/me', query_string={'page': '0'}) + assert response.status_code == 200 + + # Test page number above valid range + response = client.get('/me', query_string={'page': '999'}) + assert response.status_code == 200 + + # Test invalid page parameter + response = client.get('/me', query_string={'page': 'invalid'}) + assert response.status_code == 200 + class TestSubscriptions(LoggedInAdminBaseTest): """ @@ -583,6 +665,7 @@ def test_single_value_page(self, mock_util_config, mock_logic_config, client, re CompanyValue('AWESOME', 'awesome', ['awesome']), CompanyValue('COOL', 'cool', ['cool']) ] + mock_logic_config.LOVE_CLUSTERING_TIME_WINDOW_DAYS = mock_util_config.LOVE_CLUSTERING_TIME_WINDOW_DAYS = 1 response = client.get('/value/cool') template, response_context = recorded_templates[0] @@ -605,6 +688,7 @@ def test_all_values_page(self, mock_util_config, mock_logic_config, client, reco CompanyValue('AWESOME', 'awesome', ['awesome']), CompanyValue('COOL', 'cool', ['cool']) ] + mock_logic_config.LOVE_CLUSTERING_TIME_WINDOW_DAYS = mock_util_config.LOVE_CLUSTERING_TIME_WINDOW_DAYS = 1 response = client.get('/values') template, response_context = recorded_templates[0]