mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-22 16:16:39 +00:00
Merge pull request #528 from mouse-reeve/reading-progress
Set annual reading goals
This commit is contained in:
commit
8c4e652b2f
27 changed files with 586 additions and 80 deletions
|
@ -188,3 +188,8 @@ class ShelfForm(CustomForm):
|
|||
class Meta:
|
||||
model = models.Shelf
|
||||
fields = ['user', 'name', 'privacy']
|
||||
|
||||
class GoalForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.AnnualGoal
|
||||
fields = ['user', 'year', 'goal', 'privacy']
|
||||
|
|
32
bookwyrm/migrations/0036_annualgoal.py
Normal file
32
bookwyrm/migrations/0036_annualgoal.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-16 18:43
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0035_edition_edition_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnnualGoal',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
|
||||
('goal', models.IntegerField()),
|
||||
('year', models.IntegerField(default=2021)),
|
||||
('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'year')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -17,7 +17,7 @@ from .readthrough import ReadThrough
|
|||
|
||||
from .tag import Tag, UserTag
|
||||
|
||||
from .user import User, KeyPair
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||
from .federated_server import FederatedServer
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.apps import apps
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_data
|
||||
|
@ -18,7 +19,7 @@ from bookwyrm.utils import regex
|
|||
from .base_model import OrderedCollectionPageMixin
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .federated_server import FederatedServer
|
||||
from . import fields
|
||||
from . import fields, Review
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
|
@ -221,6 +222,57 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
return activity_object
|
||||
|
||||
|
||||
class AnnualGoal(BookWyrmModel):
|
||||
''' set a goal for how many books you read in a year '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
goal = models.IntegerField()
|
||||
year = models.IntegerField(default=timezone.now().year)
|
||||
privacy = models.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
|
||||
class Meta:
|
||||
''' unqiueness constraint '''
|
||||
unique_together = ('user', 'year')
|
||||
|
||||
def get_remote_id(self):
|
||||
''' put the year in the path '''
|
||||
return '%s/goal/%d' % (self.user.remote_id, self.year)
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
''' the books you've read this year '''
|
||||
return self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year
|
||||
).order_by('finish_date').all()
|
||||
|
||||
|
||||
@property
|
||||
def ratings(self):
|
||||
''' ratings for books read this year '''
|
||||
book_ids = [r.book.id for r in self.books]
|
||||
reviews = Review.objects.filter(
|
||||
user=self.user,
|
||||
book__in=book_ids,
|
||||
)
|
||||
return {r.book.id: r.rating for r in reviews}
|
||||
|
||||
|
||||
@property
|
||||
def progress_percent(self):
|
||||
return int(float(self.book_count / self.goal) * 100)
|
||||
|
||||
|
||||
@property
|
||||
def book_count(self):
|
||||
''' how many books you've read this year '''
|
||||
return self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year).count()
|
||||
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=User)
|
||||
#pylint: disable=unused-argument
|
||||
def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||
|
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
Binary file not shown.
Binary file not shown.
|
@ -148,11 +148,11 @@ input.toggle-control:checked ~ .modal.toggle-content {
|
|||
position: absolute;
|
||||
}
|
||||
.quote blockquote:before {
|
||||
content: "\e905";
|
||||
content: "\e906";
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.quote blockquote:after {
|
||||
content: "\e904";
|
||||
content: "\e905";
|
||||
right: 0;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?rd4abb');
|
||||
src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?rd4abb') format('truetype'),
|
||||
url('fonts/icomoon.woff?rd4abb') format('woff'),
|
||||
url('fonts/icomoon.svg?rd4abb#icomoon') format('svg');
|
||||
src: url('fonts/icomoon.eot?uh765c');
|
||||
src: url('fonts/icomoon.eot?uh765c#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?uh765c') format('truetype'),
|
||||
url('fonts/icomoon.woff?uh765c') format('woff'),
|
||||
url('fonts/icomoon.svg?uh765c#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
|
@ -25,81 +25,102 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-dots-three-vertical:before {
|
||||
content: "\e918";
|
||||
.icon-sparkle:before {
|
||||
content: "\e91a";
|
||||
}
|
||||
.icon-check:before {
|
||||
content: "\e917";
|
||||
.icon-warning:before {
|
||||
content: "\e91b";
|
||||
}
|
||||
.icon-dots-three:before {
|
||||
content: "\e916";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
.icon-book:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-arrow-right:before {
|
||||
.icon-bookmark:before {
|
||||
content: "\e91c";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-bell:before {
|
||||
.icon-arrow-right:before {
|
||||
content: "\e902";
|
||||
}
|
||||
.icon-x:before {
|
||||
.icon-bell:before {
|
||||
content: "\e903";
|
||||
}
|
||||
.icon-quote-close:before {
|
||||
.icon-x:before {
|
||||
content: "\e904";
|
||||
}
|
||||
.icon-quote-open:before {
|
||||
.icon-quote-close:before {
|
||||
content: "\e905";
|
||||
}
|
||||
.icon-image:before {
|
||||
.icon-quote-open:before {
|
||||
content: "\e906";
|
||||
}
|
||||
.icon-pencil:before {
|
||||
.icon-image:before {
|
||||
content: "\e907";
|
||||
}
|
||||
.icon-list:before {
|
||||
.icon-pencil:before {
|
||||
content: "\e908";
|
||||
}
|
||||
.icon-unlock:before {
|
||||
.icon-list:before {
|
||||
content: "\e909";
|
||||
}
|
||||
.icon-globe:before {
|
||||
.icon-unlock:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-lock:before {
|
||||
.icon-unlisted:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-globe:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-chain-broken:before {
|
||||
.icon-public:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-lock:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-chain:before {
|
||||
.icon-followers:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-chain-broken:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
.icon-comments:before {
|
||||
.icon-chain:before {
|
||||
content: "\e90e";
|
||||
}
|
||||
.icon-comment:before {
|
||||
.icon-comments:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
.icon-boost:before {
|
||||
.icon-comment:before {
|
||||
content: "\e910";
|
||||
}
|
||||
.icon-arrow-left:before {
|
||||
.icon-boost:before {
|
||||
content: "\e911";
|
||||
}
|
||||
.icon-arrow-up:before {
|
||||
.icon-arrow-left:before {
|
||||
content: "\e912";
|
||||
}
|
||||
.icon-arrow-down:before {
|
||||
.icon-arrow-up:before {
|
||||
content: "\e913";
|
||||
}
|
||||
.icon-home:before {
|
||||
.icon-arrow-down:before {
|
||||
content: "\e914";
|
||||
}
|
||||
.icon-local:before {
|
||||
.icon-home:before {
|
||||
content: "\e915";
|
||||
}
|
||||
.icon-local:before {
|
||||
content: "\e916";
|
||||
}
|
||||
.icon-dots-three:before {
|
||||
content: "\e917";
|
||||
}
|
||||
.icon-check:before {
|
||||
content: "\e918";
|
||||
}
|
||||
.icon-dots-three-vertical:before {
|
||||
content: "\e919";
|
||||
}
|
||||
.icon-search:before {
|
||||
content: "\e986";
|
||||
}
|
||||
|
|
|
@ -21,11 +21,38 @@ window.onload = function() {
|
|||
// handle aria settings on menus
|
||||
Array.from(document.getElementsByClassName('pulldown-menu'))
|
||||
.forEach(t => t.onclick = toggleMenu);
|
||||
|
||||
// display based on localstorage vars
|
||||
document.querySelectorAll('[data-hide]')
|
||||
.forEach(t => setDisplay(t));
|
||||
|
||||
// update localstorage
|
||||
Array.from(document.getElementsByClassName('set-display'))
|
||||
.forEach(t => t.onclick = updateDisplay);
|
||||
};
|
||||
|
||||
function updateDisplay(e) {
|
||||
var key = e.target.getAttribute('data-id');
|
||||
var value = e.target.getAttribute('data-value');
|
||||
window.localStorage.setItem(key, value);
|
||||
|
||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||
.forEach(t => setDisplay(t));
|
||||
}
|
||||
|
||||
function setDisplay(el) {
|
||||
var key = el.getAttribute('data-hide');
|
||||
var value = window.localStorage.getItem(key)
|
||||
if (!value) {
|
||||
el.className = el.className.replace('hidden', '');
|
||||
} else if (value != null && !!value) {
|
||||
el.className += ' hidden';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAction(e) {
|
||||
// set hover, if appropriate
|
||||
var hover = e.target.getAttribute('data-hover-target')
|
||||
var hover = e.target.getAttribute('data-hover-target');
|
||||
if (hover) {
|
||||
document.getElementById(hover).focus();
|
||||
}
|
||||
|
|
|
@ -47,8 +47,8 @@
|
|||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title">
|
||||
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
|
||||
</>
|
||||
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
|
||||
</p>
|
||||
<div class="card-header-icon is-hidden-tablet">
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" controls_text="no-book" class="delete" %}
|
||||
</div>
|
||||
|
@ -67,6 +67,15 @@
|
|||
<input class="toggle-control" type="radio" name="recent-books" id="no-book">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if goal %}
|
||||
<section class="section">
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{{ goal.year }} Reading Goal</h3>
|
||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="column is-two-thirds" id="feed">
|
||||
|
@ -85,6 +94,33 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{# announcements and system messages #}
|
||||
{% if not goal and tab == 'home' %}
|
||||
{% now 'Y' as year %}
|
||||
<section class="block hidden" aria-title="Announcements" data-hide="hide-{{ year }}-reading-goal">
|
||||
<article class="card">
|
||||
<header class="card-header">
|
||||
<h3 class="card-header-title has-background-primary has-text-white">
|
||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {{ year }} reading goal
|
||||
</h3>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
<p>Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.</p>
|
||||
|
||||
{% include 'snippets/goal_form.html' %}
|
||||
</section>
|
||||
<footer class="card-footer has-background-white-bis">
|
||||
<div class="card-footer-item is-flex-direction-column">
|
||||
<button class="button is-danger is-light is-block set-display" data-id="hide-{{ year }}-reading-goal" data-value="true">Dismiss message</button>
|
||||
<p class="help">You can set or change your reading goal any time from your <a href="{{ request.user.local_path }}">profile page</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
<hr>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# activity feed #}
|
||||
{% if not activities %}
|
||||
<p>There aren't any activities right now! Try following a user to get started</p>
|
||||
{% endif %}
|
||||
|
|
60
bookwyrm/templates/goal.html
Normal file
60
bookwyrm/templates/goal.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
<section class="block">
|
||||
<h1 class="title">{{ year }} Reading Progress</h1>
|
||||
{% if user == request.user %}
|
||||
<div class="block">
|
||||
{% if goal %}
|
||||
<input type="radio" class="toggle-control" name="edit-goal" id="hide-edit-goal" checked>
|
||||
<div class="toggle-content hidden">
|
||||
{% include 'snippets/toggle/toggle_button.html' with text="Edit goal" controls_text="show-edit-goal" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="block">
|
||||
<input type="radio" class="toggle-control" name="edit-goal" id="show-edit-goal" data-hover-target="edit-form-header">
|
||||
<div class="toggle-content{% if goal %} hidden{% endif %}">
|
||||
{% now 'Y' as year %}
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
|
||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {{ year }} reading goal
|
||||
</h2>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
<p>Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.</p>
|
||||
|
||||
{% include 'snippets/goal_form.html' with goal=goal year=year %}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not goal and user != request.user %}
|
||||
<p>{{ user.display_name }} hasn't set a reading goal for {{ year }}.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if goal %}
|
||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if goal.books %}
|
||||
<section>
|
||||
<h2 class="title">{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2>
|
||||
<div class="columns is-multiline">
|
||||
{% for book in goal.books %}
|
||||
<div class="column is-narrow">
|
||||
<div class="box">
|
||||
<a href="{{ book.book.local_path }}">
|
||||
{% include 'snippets/discover/small-book.html' with book=book.book rating=goal.ratings %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -1,7 +1,9 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% if book %}
|
||||
{% include 'snippets/book_cover.html' with book=book %}
|
||||
{% if ratings %}
|
||||
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
|
||||
{% endif %}
|
||||
|
||||
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
|
|
|
@ -29,8 +29,8 @@
|
|||
<footer class="modal-card-foot">
|
||||
<div class="columns">
|
||||
<div class="column field">
|
||||
<label for="post-status">
|
||||
<input type="checkbox" name="post-status" class="checkbox" checked>
|
||||
<label for="post_status-{{ uuid }}">
|
||||
<input type="checkbox" name="post-status" class="checkbox" id="post_status-{{ uuid }}" checked>
|
||||
Post to feed
|
||||
</label>
|
||||
{% include 'snippets/privacy_select.html' %}
|
||||
|
|
34
bookwyrm/templates/snippets/goal_form.html
Normal file
34
bookwyrm/templates/snippets/goal_form.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
<form method="post" name="goal" action="{{ request.user.local_path }}/goal/{{ year }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="year" value="{% if goal %}{{ goal.year }}{% else %}{{ year }}{% endif %}">
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<label class="label" for="id_goal">Reading goal:</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input type="number" class="input" name="goal" id="id_goal" value="{% if goal %}{{ goal.goal }}{% else %}12{% endif %}">
|
||||
</div>
|
||||
<p class="button is-static" aria-hidden="true">books</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<label class="label"><p class="mb-2">Goal privacy:</p>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True current=goal.privacy %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label for="post_status" class="label">
|
||||
<input type="checkbox" name="post-status" id="post_status" class="checkbox" checked>
|
||||
Post to feed
|
||||
</label>
|
||||
|
||||
<p>
|
||||
<button type="submit" class="button is-link">Set goal</button>
|
||||
{% if goal %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="hide-edit-goal" %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</form>
|
10
bookwyrm/templates/snippets/goal_progress.html
Normal file
10
bookwyrm/templates/snippets/goal_progress.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<p>
|
||||
{% if goal.progress_percent >= 100 %}
|
||||
Success!
|
||||
{% elif goal.progress_percent %}
|
||||
{{ goal.progress_percent }}% complete!
|
||||
{% endif %}
|
||||
{% if goal.user == request.user %}You've{% else %}{{ goal.user.display_name }} has{% endif %} read {% if request.path != goal.local_path %}<a href="{{ goal.local_path }}">{% endif %}{{ goal.book_count }} of {{ goal.goal }} books{% if request.path != goal.local_path %}</a>{% endif %}.
|
||||
</p>
|
||||
<progress class="progress is-large" value="{{ goal.book_count }}" max="{{ goal.goal }}" aria-hidden="true">{{ goal.progress_percent }}%</progress>
|
||||
|
|
@ -20,8 +20,8 @@
|
|||
<footer class="modal-card-foot">
|
||||
<div class="columns">
|
||||
<div class="column field">
|
||||
<label for="post-status">
|
||||
<input type="checkbox" name="post-status" class="checkbox" checked>
|
||||
<label for="post_status_start-{{ uuid }}">
|
||||
<input type="checkbox" name="post-status" class="checkbox" id="post_status_start-{{ uuid }}" checked>
|
||||
Post to feed
|
||||
</label>
|
||||
{% include 'snippets/privacy_select.html' %}
|
||||
|
|
|
@ -40,6 +40,17 @@
|
|||
<small><a href="{{ user.local_path }}/shelves">See all {{ shelf_count }} shelves</a></small>
|
||||
</div>
|
||||
|
||||
{% if goal %}
|
||||
<div class="block">
|
||||
<h2 class="title">{% now 'Y' %} Reading Goal</h2>
|
||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||
</div>
|
||||
{% elif user == request.user %}
|
||||
<div class="block">
|
||||
<h2 class="title is-4"><a href="{{ user.local_path }}/goal/{% now 'Y' %}">Set a reading goal for {% now 'Y' %}</a></h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<div class="block">
|
||||
<h2 class="title">User Activity</h2>
|
||||
|
|
114
bookwyrm/tests/views/test_goal.py
Normal file
114
bookwyrm/tests/views/test_goal.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
''' test for app action functionality '''
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.template.response import TemplateResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookwyrm import models, views
|
||||
|
||||
|
||||
class GoalViews(TestCase):
|
||||
''' viewing and creating statuses '''
|
||||
def setUp(self):
|
||||
''' we need basic test data and mocks '''
|
||||
self.factory = RequestFactory()
|
||||
self.local_user = models.User.objects.create_user(
|
||||
'mouse@local.com', 'mouse@mouse.com', 'mouseword',
|
||||
local=True, localname='mouse',
|
||||
remote_id='https://example.com/users/mouse',
|
||||
)
|
||||
self.rat = models.User.objects.create_user(
|
||||
'rat@local.com', 'rat@rat.com', 'ratword',
|
||||
local=True, localname='rat',
|
||||
remote_id='https://example.com/users/rat',
|
||||
)
|
||||
self.book = models.Edition.objects.create(
|
||||
title='Example Edition',
|
||||
remote_id='https://example.com/book/1',
|
||||
)
|
||||
self.anonymous_user = AnonymousUser
|
||||
self.anonymous_user.is_authenticated = False
|
||||
|
||||
|
||||
def test_goal_page_no_goal(self):
|
||||
''' view a reading goal page for another's unset goal '''
|
||||
view = views.Goal.as_view()
|
||||
request = self.factory.get('')
|
||||
request.user = self.rat
|
||||
|
||||
result = view(request, self.local_user.localname, 2020)
|
||||
self.assertEqual(result.status_code, 404)
|
||||
|
||||
def test_goal_page_no_goal_self(self):
|
||||
''' view a reading goal page for your own unset goal '''
|
||||
view = views.Goal.as_view()
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
|
||||
result = view(request, self.local_user.localname, 2020)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
|
||||
|
||||
def test_goal_page_anonymous(self):
|
||||
''' can't view it without login '''
|
||||
view = views.Goal.as_view()
|
||||
request = self.factory.get('')
|
||||
request.user = self.anonymous_user
|
||||
|
||||
result = view(request, self.local_user.localname, 2020)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
||||
def test_goal_page_public(self):
|
||||
''' view a user's public goal '''
|
||||
models.AnnualGoal.objects.create(
|
||||
user=self.local_user,
|
||||
year=2020,
|
||||
goal=128937123,
|
||||
privacy='public')
|
||||
view = views.Goal.as_view()
|
||||
request = self.factory.get('')
|
||||
request.user = self.rat
|
||||
|
||||
result = view(request, self.local_user.localname, 2020)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
|
||||
def test_goal_page_private(self):
|
||||
''' view a user's private goal '''
|
||||
models.AnnualGoal.objects.create(
|
||||
user=self.local_user,
|
||||
year=2020,
|
||||
goal=15,
|
||||
privacy='followers')
|
||||
view = views.Goal.as_view()
|
||||
request = self.factory.get('')
|
||||
request.user = self.rat
|
||||
|
||||
result = view(request, self.local_user.localname, 2020)
|
||||
self.assertEqual(result.status_code, 404)
|
||||
|
||||
|
||||
def test_create_goal(self):
|
||||
''' create a new goal '''
|
||||
view = views.Goal.as_view()
|
||||
request = self.factory.post('', {
|
||||
'user': self.local_user.id,
|
||||
'goal': 10,
|
||||
'year': 2020,
|
||||
'privacy': 'unlisted',
|
||||
'post-status': True
|
||||
})
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
view(request, self.local_user.localname, 2020)
|
||||
|
||||
goal = models.AnnualGoal.objects.get()
|
||||
self.assertEqual(goal.user, self.local_user)
|
||||
self.assertEqual(goal.goal, 10)
|
||||
self.assertEqual(goal.year, 2020)
|
||||
self.assertEqual(goal.privacy, 'unlisted')
|
||||
|
||||
status = models.GeneratedNote.objects.get()
|
||||
self.assertEqual(status.user, self.local_user)
|
||||
self.assertEqual(status.privacy, 'unlisted')
|
|
@ -75,6 +75,9 @@ urlpatterns = [
|
|||
re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()),
|
||||
re_path(r'^edit-profile/?$', views.EditUser.as_view()),
|
||||
|
||||
# reading goals
|
||||
re_path(r'%s/goal/(?P<year>\d{4})/?$' % user_path, views.Goal.as_view()),
|
||||
|
||||
# statuses
|
||||
re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()),
|
||||
re_path(r'%s/activity/?$' % status_path, views.Status.as_view()),
|
||||
|
|
|
@ -7,6 +7,7 @@ from .direct_message import DirectMessage
|
|||
from .error import not_found_page, server_error_page
|
||||
from .follow import follow, unfollow
|
||||
from .follow import accept_follow_request, delete_follow_request, handle_accept
|
||||
from .goal import Goal
|
||||
from .import_data import Import, ImportStatus
|
||||
from .interaction import Favorite, Unfavorite, Boost, Unboost
|
||||
from .invite import ManageInvites, Invite
|
||||
|
|
79
bookwyrm/views/goal.py
Normal file
79
bookwyrm/views/goal.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
''' non-interactive pages '''
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.shortcuts import redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.broadcast import broadcast
|
||||
from bookwyrm.status import create_generated_note
|
||||
from .helpers import get_user_from_username, object_visible_to_user
|
||||
|
||||
|
||||
# pylint: disable= no-self-use
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class Goal(View):
|
||||
''' track books for the year '''
|
||||
def get(self, request, username, year):
|
||||
''' reading goal page '''
|
||||
user = get_user_from_username(username)
|
||||
year = int(year)
|
||||
goal = models.AnnualGoal.objects.filter(
|
||||
year=year, user=user
|
||||
).first()
|
||||
if not goal and user != request.user:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
if goal and not object_visible_to_user(request.user, goal):
|
||||
return HttpResponseNotFound()
|
||||
|
||||
data = {
|
||||
'title': '%s\'s %d Reading' % (user.display_name, year),
|
||||
'goal_form': forms.GoalForm(instance=goal),
|
||||
'goal': goal,
|
||||
'user': user,
|
||||
'year': year,
|
||||
}
|
||||
return TemplateResponse(request, 'goal.html', data)
|
||||
|
||||
|
||||
def post(self, request, username, year):
|
||||
''' update or create an annual goal '''
|
||||
user = get_user_from_username(username)
|
||||
if user != request.user:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
year = int(year)
|
||||
goal = models.AnnualGoal.objects.filter(
|
||||
year=year, user=request.user
|
||||
).first()
|
||||
form = forms.GoalForm(request.POST, instance=goal)
|
||||
if not form.is_valid():
|
||||
data = {
|
||||
'title': '%s\'s %d Reading' % (goal.user.display_name, year),
|
||||
'goal_form': form,
|
||||
'goal': goal,
|
||||
'year': year,
|
||||
}
|
||||
return TemplateResponse(request, 'goal.html', data)
|
||||
goal = form.save()
|
||||
|
||||
if request.POST.get('post-status'):
|
||||
# create status, if appropraite
|
||||
status = create_generated_note(
|
||||
request.user,
|
||||
'set a goal to read %d books in %d' % (goal.goal, goal.year),
|
||||
privacy=goal.privacy
|
||||
)
|
||||
broadcast(
|
||||
request.user,
|
||||
status.to_create_activity(request.user),
|
||||
software='bookwyrm')
|
||||
|
||||
# re-format the activity for non-bookwyrm servers
|
||||
remote_activity = status.to_create_activity(request.user, pure=True)
|
||||
broadcast(request.user, remote_activity, software='other')
|
||||
|
||||
return redirect(request.headers.get('Referer', '/'))
|
|
@ -34,16 +34,17 @@ def is_bookworm_request(request):
|
|||
return True
|
||||
|
||||
|
||||
def status_visible_to_user(viewer, status):
|
||||
''' is a user authorized to view a status? '''
|
||||
if viewer == status.user or status.privacy in ['public', 'unlisted']:
|
||||
def object_visible_to_user(viewer, obj):
|
||||
''' is a user authorized to view an object? '''
|
||||
if viewer == obj.user or obj.privacy in ['public', 'unlisted']:
|
||||
return True
|
||||
if status.privacy == 'followers' and \
|
||||
status.user.followers.filter(id=viewer.id).first():
|
||||
return True
|
||||
if status.privacy == 'direct' and \
|
||||
status.mention_users.filter(id=viewer.id).first():
|
||||
if obj.privacy == 'followers' and \
|
||||
obj.user.followers.filter(id=viewer.id).first():
|
||||
return True
|
||||
if isinstance(obj, models.Status):
|
||||
if obj.privacy == 'direct' and \
|
||||
obj.mention_users.filter(id=viewer.id).first():
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_activity_feed(
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.contrib.auth.decorators import login_required
|
|||
from django.core.paginator import Paginator
|
||||
from django.db.models import Avg, Max
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
|
||||
|
@ -86,12 +87,18 @@ class Feed(View):
|
|||
activities = get_activity_feed(
|
||||
request.user, ['public', 'followers'])
|
||||
paginated = Paginator(activities, PAGE_LENGTH)
|
||||
|
||||
goal = models.AnnualGoal.objects.filter(
|
||||
user=request.user, year=timezone.now().year
|
||||
).first()
|
||||
data = {
|
||||
'title': 'Updates Feed',
|
||||
'user': request.user,
|
||||
'suggested_books': suggested_books,
|
||||
'activities': paginated.page(page),
|
||||
'tab': tab,
|
||||
'goal': goal,
|
||||
'goal_form': forms.GoalForm(),
|
||||
}
|
||||
return TemplateResponse(request, 'feed.html', data)
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ from bookwyrm.settings import DOMAIN
|
|||
from bookwyrm.status import create_notification, delete_status
|
||||
from bookwyrm.utils import regex
|
||||
from .helpers import get_user_from_username, handle_remote_webfinger
|
||||
from .helpers import is_api_request, is_bookworm_request, status_visible_to_user
|
||||
from .helpers import is_api_request, is_bookworm_request, object_visible_to_user
|
||||
|
||||
|
||||
# pylint: disable= no-self-use
|
||||
|
@ -35,7 +35,7 @@ class Status(View):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
# make sure the user is authorized to see the status
|
||||
if not status_visible_to_user(request.user, status):
|
||||
if not object_visible_to_user(request.user, status):
|
||||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
|
|
|
@ -9,6 +9,7 @@ from django.core.paginator import Paginator
|
|||
from django.http import HttpResponseNotFound
|
||||
from django.shortcuts import redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
|
||||
|
@ -17,6 +18,7 @@ from bookwyrm.activitypub import ActivitypubResponse
|
|||
from bookwyrm.broadcast import broadcast
|
||||
from bookwyrm.settings import PAGE_LENGTH
|
||||
from .helpers import get_activity_feed, get_user_from_username, is_api_request
|
||||
from .helpers import object_visible_to_user
|
||||
|
||||
|
||||
# pylint: disable= no-self-use
|
||||
|
@ -70,6 +72,10 @@ class User(View):
|
|||
queryset=models.Status.objects.filter(user=user)
|
||||
)
|
||||
paginated = Paginator(activities, PAGE_LENGTH)
|
||||
goal = models.AnnualGoal.objects.filter(
|
||||
user=user, year=timezone.now().year).first()
|
||||
if not object_visible_to_user(request.user, goal):
|
||||
goal = None
|
||||
data = {
|
||||
'title': user.name,
|
||||
'user': user,
|
||||
|
@ -77,6 +83,7 @@ class User(View):
|
|||
'shelves': shelf_preview,
|
||||
'shelf_count': shelves.count(),
|
||||
'activities': paginated.page(page),
|
||||
'goal': goal,
|
||||
}
|
||||
|
||||
return TemplateResponse(request, 'user.html', data)
|
||||
|
|
Loading…
Reference in a new issue