Graph de famille en frontend (#820)

* Remove graphviz and use cytoscape.js instead

* Frontend generated graphs
* Make installation easier and faster
* Better user experience
* Family api and improved interface
* Fix url history when using 0, improve button selection and reset reverse with reset button
* Use klay layout
* Add js translations and apply review comments
This commit is contained in:
Bartuccio Antoine
2024-09-17 12:10:06 +02:00
committed by GitHub
parent bf96d8a10c
commit f624b7c66d
29 changed files with 1332 additions and 684 deletions

View File

@ -1,10 +1,19 @@
from typing import Annotated
import annotated_types
from django.conf import settings
from django.http import HttpResponse
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import PermissionDenied
from club.models import Mailing
from core.schemas import MarkdownSchema
from core.api_permissions import CanView
from core.models import User
from core.schemas import (
FamilyGodfatherSchema,
MarkdownSchema,
UserFamilySchema,
)
from core.templatetags.renderer import markdown
@ -27,3 +36,45 @@ class MailingListController(ControllerBase):
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4
@api_controller("/family")
class FamilyController(ControllerBase):
@route.get(
"/{user_id}",
permissions=[CanView],
response=UserFamilySchema,
url_name="family_graph",
)
def get_family_graph(
self,
user_id: int,
godfathers_depth: DepthValue = DEFAULT_DEPTH,
godchildren_depth: DepthValue = DEFAULT_DEPTH,
):
user: User = self.get_object_or_exception(User, pk=user_id)
relations = user.get_family(godfathers_depth, godchildren_depth)
if not relations:
# If the user has no relations, return only the user
# He is alone in its family, but the family exists nonetheless
return {"users": [user], "relationships": []}
user_ids = {r.from_user_id for r in relations} | {
r.to_user_id for r in relations
}
return {
"users": User.objects.filter(id__in=user_ids).distinct(),
"relationships": (
[
FamilyGodfatherSchema(
godchild=r.from_user_id, godfather=r.to_user_id
)
for r in relations
]
),
}

View File

@ -78,7 +78,7 @@ class CanView(BasePermission):
"""Check that this user has the permission to view the object of this route.
Wrap the `user.can_view(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
@ -94,7 +94,7 @@ class CanEdit(BasePermission):
"""Check that this user has the permission to edit the object of this route.
Wrap the `user.can_edit(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
@ -110,7 +110,7 @@ class IsOwner(BasePermission):
"""Check that this user owns the object of this route.
Wrap the `user.is_owner(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:

View File

@ -57,6 +57,7 @@ from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from pydantic.v1 import NonNegativeInt
if TYPE_CHECKING:
from club.models import Club
@ -606,6 +607,41 @@ class User(AbstractBaseUser):
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
)
def get_family(
self,
godfathers_depth: NonNegativeInt = 4,
godchildren_depth: NonNegativeInt = 4,
) -> set[User.godfathers.through]:
"""Get the family of the user, with the given depth.
Args:
godfathers_depth: The number of generations of godfathers to fetch
godchildren_depth: The number of generations of godchildren to fetch
Returns:
A list of family relationships in this user's family
"""
res = []
for depth, key, reverse_key in [
(godfathers_depth, "from_user_id", "to_user_id"),
(godchildren_depth, "to_user_id", "from_user_id"),
]:
if depth == 0:
continue
links = list(User.godfathers.through.objects.filter(**{key: self.id}))
res.extend(links)
for _ in range(1, depth):
ids = [getattr(c, reverse_key) for c in links]
links = list(
User.godfathers.through.objects.filter(
**{f"{key}__in": ids}
).exclude(id__in=[r.id for r in res])
)
if not links:
break
res.extend(links)
return set(res)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Sends an email to this User."""
if from_email is None:

View File

@ -1,3 +1,4 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from ninja import ModelSchema, Schema
from core.models import User
@ -13,3 +14,41 @@ class SimpleUserSchema(ModelSchema):
class MarkdownSchema(Schema):
text: str
class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile"""
class Meta:
model = User
fields = ["id", "nick_name", "first_name", "last_name"]
display_name: str
profile_url: str
profile_pict: str
@staticmethod
def resolve_display_name(obj: User) -> str:
return obj.get_display_name()
@staticmethod
def resolve_profile_url(obj: User) -> str:
return obj.get_absolute_url()
@staticmethod
def resolve_profile_pict(obj: User) -> str:
if obj.profile_pict_id is None:
return staticfiles_storage.url("core/img/unknown.jpg")
return obj.profile_pict.get_download_url()
class FamilyGodfatherSchema(Schema):
godfather: int
godchild: int
class UserFamilySchema(Schema):
"""Represent a graph of a user's family"""
users: list[UserProfileSchema]
relationships: list[FamilyGodfatherSchema]

View File

@ -89,7 +89,7 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
if (!url){
url = new URL(window.location.href);
}
if (!value) {
if (value === undefined || value === null || value === "") {
// If the value is null, undefined or empty => delete it
url.searchParams.delete(key)
} else if (Array.isArray(value)) {

View File

@ -239,6 +239,7 @@ a:not(.button) {
padding: 9px 13px;
border: none;
text-decoration: none;
border-radius: 5px;
&.btn-blue {
background-color: $deepblue;

View File

@ -0,0 +1,267 @@
async function get_graph_data(url, godfathers_depth, godchildren_depth) {
let data = await (
await fetch(
`${url}?godfathers_depth=${godfathers_depth}&godchildren_depth=${godchildren_depth}`,
)
).json();
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function create_graph(container, data, active_user_id) {
let cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container: container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: 'DOWN',
nodePlacement: 'INTERACTIVE',
layoutHierarchy: true
}
}
});
let active_user = cy
.getElementById(active_user_id)
.style("shape", "rectangle");
/* Reset graph */
let reset_graph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
let on_node_tap = (el) => {
reset_graph();
/* Create path on graph if selected isn't the targeted user */
if (el === active_user) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
cy.elements()
.aStar({
root: el,
goal: active_user,
})
.path.forEach((el) => {
el.removeClass("not-traversed");
el.addClass("traversed");
});
};
cy.on("tap", "node", (tapped) => {
on_node_tap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
if (cy.cxtmenu === undefined) {
console.error(
"ctxmenu isn't loaded, context menu won't be available on graphs",
);
return cy;
}
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: function (el) {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: function (el) {
on_node_tap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: function (el) {
reset_graph();
},
},
],
});
return cy;
}
document.addEventListener("alpine:init", () => {
/*
This needs some constants to be set before the document has been loaded
api_url: base url for fetching the tree as a string
active_user: id of the user to fetch the tree from
depth_min: minimum tree depth for godfathers and godchildren as an int
depth_max: maximum tree depth for godfathers and godchildren as an int
*/
const default_depth = 2;
if (
typeof api_url === "undefined" ||
typeof active_user === "undefined" ||
typeof depth_min === "undefined" ||
typeof depth_max === "undefined"
) {
console.error("Some constants are not set before using the family_graph script, please look at the documentation");
return;
}
function get_initial_depth(prop) {
let value = parseInt(initialUrlParams.get(prop));
if (isNaN(value) || value < depth_min || value > depth_max) {
return default_depth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathers_depth: get_initial_depth("godfathers_depth"),
godchildren_depth: get_initial_depth("godchildren_depth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
graph: undefined,
graph_data: {},
async init() {
let delayed_fetch = Alpine.debounce(async () => {
this.fetch_graph_data();
}, 100);
["godfathers_depth", "godchildren_depth"].forEach((param) => {
this.$watch(param, async (value) => {
if (value < depth_min || value > depth_max) {
return;
}
update_query_string(param, value, History.REPLACE);
delayed_fetch();
});
});
this.$watch("reverse", async (value) => {
update_query_string("reverse", value, History.REPLACE);
this.reverse_graph();
});
this.$watch("graph_data", async () => {
await this.generate_graph();
if (this.reverse) {
await this.reverse_graph();
}
});
this.fetch_graph_data();
},
async screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = gettext("family_tree.%(extension)s", "jpg");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
async reset() {
this.reverse = false;
this.godfathers_depth = default_depth;
this.godchildren_depth = default_depth;
},
async reverse_graph() {
this.graph.elements((el) => {
el.position(new Object({ x: -el.position().x, y: -el.position().y }));
});
this.graph.center(this.graph.elements());
},
async fetch_graph_data() {
this.graph_data = await get_graph_data(
api_url,
this.godfathers_depth,
this.godchildren_depth,
);
},
async generate_graph() {
this.loading = true;
this.graph = create_graph(
$(this.$refs.graph),
this.graph_data,
active_user,
);
this.loading = false;
},
}));
});

View File

@ -1,3 +1,85 @@
.graph {
width: 100%;
height: 70vh;
display: block;
}
.graph-toolbar {
margin-top: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
justify-content: space-around;
gap: 30px;
.toolbar-column{
display: flex;
flex-direction: column;
gap: 20px;
min-width: 30%;
}
.toolbar-input {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
width: 100%;
label {
max-width: 70%;
text-align: left;
margin-bottom: 0;
}
.depth-choice {
white-space: nowrap;
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
}
button {
background: none;
& > .fa {
border-radius: 50%;
font-size: 12px;
padding: 5px;
}
&:enabled > .fa {
background-color: #354a5f;
color: white;
}
&:enabled:hover > .fa {
color: white;
background-color: #35405f; // just a bit darker
}
&:disabled > .fa {
background-color: gray;
color: white;
}
}
}
input {
align-self: center;
max-width: 40px;
}
}
@media screen and (max-width: 500px) {
flex-direction: column;
gap: 20px;
.toolbar-column {
min-width: 100%;
}
}
}
.container {
display: flex;
flex-direction: column;
@ -9,6 +91,14 @@
margin: 0;
}
}
#family-tree-link {
display: inline-block;
margin-top: 10px;
text-align: center;
@media (min-width: 450px) {
margin-right: auto;
}
}
.users {
display: flex;
@ -90,7 +180,7 @@
}
}
&:last-of-type {
&.delete {
margin-top: 10px;
display: block;
text-align: center;
@ -98,7 +188,7 @@
@media (max-width: 375px) {
position: absolute;
bottom: 0%;
bottom: 0;
right: 0;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -112,7 +112,7 @@
{% macro delete_godfather(user, profile, godfather, is_father) %}
{% if user == profile or user.is_root or user.is_board_member %}
<a href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endmacro %}

View File

@ -11,14 +11,20 @@
{% block content %}
<div class="container">
<a href="{{ url('core:user_godfathers_tree_pict', user_id=profile.id) }}?family">
{% trans %}Show family picture{% endtrans %}
</a>
{% if godchildren or godfathers %}
<a
href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}"
class="btn btn-blue"
id="family-tree-link"
>
{% trans %}Show family tree{% endtrans %}
</a>
{% endif %}
<h4>{% trans %}Godfathers / Godmothers{% endtrans %}</h4>
{% if profile.godfathers.exists() %}
{% if godfathers %}
<ul class="users">
{% for u in profile.godfathers.all() %}
{% for u in godfathers %}
<li class="users-card">
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }}
@ -28,17 +34,14 @@
{% endfor %}
</ul>
<a href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}">
{% trans %}Show ancestors tree{% endtrans %}
</a>
{% else %}
<p>{% trans %}No godfathers / godmothers{% endtrans %}
{% endif %}
<h4>{% trans %}Godchildren{% endtrans %}</h4>
{% if profile.godchildren.exists() %}
{% if godchildren %}
<ul class="users">
{% for u in profile.godchildren.all() %}
{% for u in godchildren %}
<li class="users-card">
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }}
@ -47,10 +50,6 @@
</li>
{% endfor %}
</ul>
<a href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}?descent">
{% trans %}Show descent tree{% endtrans %}
</a>
{% else %}
<p>{% trans %}No godchildren{% endtrans %}
{% endif %}

View File

@ -1,54 +1,105 @@
{% extends "core/base.jinja" %}
{% set depth_min=0 %}
{% set depth_max=10 %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ scss('user/user_godfathers.scss') }}">
{%- endblock -%}
{% block additional_js %}
<script src="{{ static("vendored/cytoscape/cytoscape.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/cytoscape-cxtmenu.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/klay.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/cytoscape-klay.min.js") }}" defer></script>
<script src="{{ static("user/js/family_graph.js") }}" defer></script>
{% endblock %}
{% block title %}
{% if param == "godchildren" %}
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s godchildren{% endtrans %}
{% else %}
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s godfathers{% endtrans %}
{% endif %}
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s family tree{% endtrans %}
{% endblock %}
{% macro display_members_list(user) %}
{% if user.__getattribute__(param).exists() %}
<ul>
{% for u in user.__getattribute__(param).all() %}
<li>
<a href="{{ url("core:user_godfathers", user_id=u.id) }}">
{{ u.get_short_name() }}
</a>
{% if u in members_set %}
{% trans %}Already seen (check above){% endtrans %}
{% else %}
{{ members_set.add(u) or "" }}
{{ display_members_list(u) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}
{% block content %}
<p><a href="{{ url("core:user_godfathers", user_id=profile.id) }}">
{% trans %}Back to family{% endtrans %}</a></p>
{% if profile.__getattribute__(param).exists() %}
{% if param == "godchildren" %}
<p><a href="{{ url("core:user_godfathers_tree_pict", user_id=profile.id) }}?descent">
{% trans %}Show a picture of the tree{% endtrans %}</a></p>
<h4>{% trans u=profile.get_short_name() %}Descent tree of {{ u }}{% endtrans %}</h4>
{% else %}
<p><a href="{{ url("core:user_godfathers_tree_pict", user_id=profile.id) }}?ancestors">
{% trans %}Show a picture of the tree{% endtrans %}</a></p>
<h4>{% trans u=profile.get_short_name() %}Ancestors tree of {{ u }}{% endtrans %}</h4>
{% endif %}
{{ members_set.add(profile) or "" }}
{{ display_members_list(profile) }}
{% else %}
{% if param == "godchildren" %}
<p>{% trans %}No godchildren{% endtrans %}
{% else %}
<p>{% trans %}No godfathers / godmothers{% endtrans %}
{% endif %}
{% endif %}
<div x-data="graph" :aria-busy="loading">
<div class="graph-toolbar">
<div class="toolbar-column">
<div class="toolbar-input">
<label for="godfather-depth-input">
{% trans min=depth_min, max=depth_max %}Max godfather depth between {{ min }} and {{ max }}{% endtrans %}
</label>
<span class="depth-choice">
<button
@click="godfathers_depth--"
:disabled="godfathers_depth <= {{ depth_min }}"
><i class="fa fa-minus fa-xs"></i></button>
<input
x-model="godfathers_depth"
x-ref="godfather_depth_input"
type="number"
name="godfathers_depth"
id="godfather-depth-input"
min="{{ depth_min }}"
max="{{ depth_max }}"
/>
<button
@click="godfathers_depth++"
:disabled="godfathers_depth >= {{ depth_max }}"
><i class="fa fa-plus"
></i></button>
</span>
</div>
<div class="toolbar-input">
<label for="godchild-depth-input">
{% trans min=depth_min, max=depth_max %}Max godchildren depth between {{ min }} and {{ max }}{% endtrans %}
</label>
<span class="depth-choice">
<button
@click="godchildren_depth--"
:disabled="godchildren_depth <= {{ depth_min }}"
><i
class="fa fa-minus fa-xs"
></i></button>
<input
x-model="godchildren_depth"
type="number"
name="godchildren_depth"
id="godchild-depth-input"
min="{{ depth_min }}"
max="{{ depth_max }}"
/>
<button
@click="godchildren_depth++"
:disabled="godchildren_depth >= {{ depth_max }}"
><i class="fa fa-plus"
></i></button>
</span>
</div>
</div>
<div class="toolbar-column">
<div class="toolbar-input">
<label for="reverse-checkbox">{% trans %}Reverse{% endtrans %}</label>
<input x-model="reverse" type="checkbox" name="reverse" id="reverse-checkbox">
</div>
<button class="btn btn-grey" @click="reset">
{% trans %}Reset{% endtrans %}
</button>
<button class="btn btn-grey" @click="screenshot">
<i class="fa fa-camera"></i>
{% trans %}Save{% endtrans %}
</button>
</div>
</div>
<div x-ref="graph" class="graph"></div>
</div>
<script>
const api_url = "{{ api_url }}";
const active_user = "{{ object.id }}"
const depth_min = {{ depth_min }};
const depth_max = {{ depth_max }};
</script>
{% endblock %}

0
core/tests/__init__.py Normal file
View File

187
core/tests/test_family.py Normal file
View File

@ -0,0 +1,187 @@
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from core.baker_recipes import subscriber_user
from core.models import User
class TestFetchFamilyApi(TestCase):
@classmethod
def setUpTestData(cls):
# Relations (A -> B means A is the godchild of B):
# main_user -> user0 -> user3
# -> user1 -> user6 -> user7 -> user8 -> user9
# -> user2 -> user10
#
# main_user <- user3 <- user11
# <- user12
# <- user4 <- user13
# <- user14
# <- user15 <- user16
# <- user5
cls.main_user = baker.make(User)
cls.users = baker.make(User, _quantity=17)
cls.main_user.godfathers.add(*cls.users[0:3])
cls.main_user.godchildren.add(*cls.users[3:6])
cls.users[1].godfathers.add(cls.users[6])
cls.users[6].godfathers.add(cls.users[7])
cls.users[7].godfathers.add(cls.users[8])
cls.users[8].godfathers.add(cls.users[9])
cls.users[2].godfathers.add(cls.users[10])
cls.users[3].godchildren.add(cls.users[11], cls.users[12])
cls.users[4].godchildren.add(*cls.users[13:16])
cls.users[15].godchildren.add(cls.users[16])
cls.root_user = baker.make(User, is_superuser=True)
cls.subscriber_user = subscriber_user.make()
def setUp(self):
self.maxDiff = None
def test_fetch_family_forbidden(self):
# Anonymous user
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 403
self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 403
def test_fetch_family_hidden_user(self):
self.main_user.is_subscriber_viewable = False
self.main_user.save()
for user_to_login, error_code in [
(self.main_user, 200),
(self.subscriber_user, 403),
(self.root_user, 200),
]:
self.client.force_login(user_to_login)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == error_code
def test_fetch_family_with_zero_depth(self):
"""Fetch the family with a depth of 0."""
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=0&godchildren_depth=0"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [self.main_user.id]
assert response.json()["relationships"] == []
def test_fetch_empty_family(self):
empty_user = baker.make(User)
self.client.force_login(empty_user)
response = self.client.get(reverse("api:family_graph", args=[empty_user.id]))
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [empty_user.id]
assert response.json()["relationships"] == []
def test_fetch_whole_family(self):
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=10&godchildren_depth=10"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
self.main_user.id,
*[u.id for u in self.users],
]
self.assertCountEqual(
response.json()["relationships"],
[
{"godfather": self.users[0].id, "godchild": self.main_user.id},
{"godfather": self.users[1].id, "godchild": self.main_user.id},
{"godfather": self.users[2].id, "godchild": self.main_user.id},
{"godfather": self.main_user.id, "godchild": self.users[3].id},
{"godfather": self.main_user.id, "godchild": self.users[4].id},
{"godfather": self.main_user.id, "godchild": self.users[5].id},
{"godfather": self.users[6].id, "godchild": self.users[1].id},
{"godfather": self.users[7].id, "godchild": self.users[6].id},
{"godfather": self.users[8].id, "godchild": self.users[7].id},
{"godfather": self.users[9].id, "godchild": self.users[8].id},
{"godfather": self.users[10].id, "godchild": self.users[2].id},
{"godfather": self.users[3].id, "godchild": self.users[11].id},
{"godfather": self.users[3].id, "godchild": self.users[12].id},
{"godfather": self.users[4].id, "godchild": self.users[13].id},
{"godfather": self.users[4].id, "godchild": self.users[14].id},
{"godfather": self.users[4].id, "godchild": self.users[15].id},
{"godfather": self.users[15].id, "godchild": self.users[16].id},
],
)
def test_fetch_family_first_level(self):
"""Fetch only the first level of the family."""
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=1&godchildren_depth=1"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
self.main_user.id,
*[u.id for u in self.users[:6]],
]
self.assertCountEqual(
response.json()["relationships"],
[
{"godfather": self.users[0].id, "godchild": self.main_user.id},
{"godfather": self.users[1].id, "godchild": self.main_user.id},
{"godfather": self.users[2].id, "godchild": self.main_user.id},
{"godfather": self.main_user.id, "godchild": self.users[3].id},
{"godfather": self.main_user.id, "godchild": self.users[4].id},
{"godfather": self.main_user.id, "godchild": self.users[5].id},
],
)
def test_fetch_family_only_godfathers(self):
"""Fetch only the godfathers."""
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=10&godchildren_depth=0"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
self.main_user.id,
*[u.id for u in self.users[:3]],
*[u.id for u in self.users[6:11]],
]
self.assertCountEqual(
response.json()["relationships"],
[
{"godfather": self.users[0].id, "godchild": self.main_user.id},
{"godfather": self.users[1].id, "godchild": self.main_user.id},
{"godfather": self.users[2].id, "godchild": self.main_user.id},
{"godfather": self.users[6].id, "godchild": self.users[1].id},
{"godfather": self.users[7].id, "godchild": self.users[6].id},
{"godfather": self.users[8].id, "godchild": self.users[7].id},
{"godfather": self.users[9].id, "godchild": self.users[8].id},
{"godfather": self.users[10].id, "godchild": self.users[2].id},
],
)
def test_nb_queries(self):
# The number of queries should be 1 per level of existing depth.
with self.assertNumQueries(0):
self.main_user.get_family(godfathers_depth=0, godchildren_depth=0)
with self.assertNumQueries(3):
self.main_user.get_family(godfathers_depth=3, godchildren_depth=0)
with self.assertNumQueries(3):
self.main_user.get_family(godfathers_depth=0, godchildren_depth=3)
with self.assertNumQueries(6):
self.main_user.get_family(godfathers_depth=3, godchildren_depth=3)
with self.assertNumQueries(4):
# If a level is empty, the next ones should not be queried.
self.main_user.get_family(godfathers_depth=0, godchildren_depth=10)

View File

@ -112,11 +112,6 @@ urlpatterns = [
UserGodfathersTreeView.as_view(),
name="user_godfathers_tree",
),
path(
"user/<int:user_id>/godfathers/tree/pict/",
UserGodfathersTreePictureView.as_view(),
name="user_godfathers_tree_pict",
),
path(
"user/<int:user_id>/godfathers/<int:godfather_id>/<bool:is_father>/delete/",
delete_user_godfather,

View File

@ -335,9 +335,40 @@ class UserGodfathersForm(forms.Form):
label=_("Add"),
)
user = AutoCompleteSelectField(
"users", required=True, label=_("Select user"), help_text=None
"users", required=True, label=_("Select user"), help_text=""
)
def __init__(self, *args, user: User, **kwargs):
super().__init__(*args, **kwargs)
self.target_user = user
def clean_user(self):
other_user = self.cleaned_data.get("user")
if not other_user:
raise ValidationError(_("This user does not exist"))
if other_user == self.target_user:
raise ValidationError(_("You cannot be related to yourself"))
return other_user
def clean(self):
super().clean()
if not self.is_valid():
return self.cleaned_data
other_user = self.cleaned_data["user"]
if self.cleaned_data["type"] == "godfather":
if self.target_user.godfathers.contains(other_user):
self.add_error(
"user",
_("%s is already your godfather") % (other_user.get_short_name()),
)
else:
if self.target_user.godchildren.contains(other_user):
self.add_error(
"user",
_("%s is already your godchild") % (other_user.get_short_name()),
)
return self.cleaned_data
class PagePropForm(forms.ModelForm):
error_css_class = "error"

View File

@ -34,7 +34,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
from django.http import Http404, HttpResponse
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
@ -323,7 +323,7 @@ def delete_user_godfather(request, user_id, godfather_id, is_father):
return redirect("core:user_godfathers", user_id=user_id)
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView, FormView):
"""Display a user's godfathers."""
model = User
@ -331,27 +331,23 @@ class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_godfathers.jinja"
current_tab = "godfathers"
form_class = UserGodfathersForm
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = UserGodfathersForm(request.POST)
if self.form.is_valid() and self.form.cleaned_data["user"] != self.object:
if self.form.cleaned_data["type"] == "godfather":
self.object.godfathers.add(self.form.cleaned_data["user"])
self.object.save()
else:
self.object.godchildren.add(self.form.cleaned_data["user"])
self.object.save()
self.form = UserGodfathersForm()
return super().get(request, *args, **kwargs)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"user": self.object}
def form_valid(self, form):
if form.cleaned_data["type"] == "godfather":
self.object.godfathers.add(form.cleaned_data["user"])
else:
self.object.godchildren.add(form.cleaned_data["user"])
return redirect("core:user_godfathers", user_id=self.object.id)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
try:
kwargs["form"] = self.form
except:
kwargs["form"] = UserGodfathersForm()
return kwargs
return super().get_context_data(**kwargs) | {
"godfathers": list(self.object.godfathers.select_related("profile_pict")),
"godchildren": list(self.object.godchildren.select_related("profile_pict")),
}
class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
@ -365,86 +361,12 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
if "descent" in self.request.GET:
kwargs["param"] = "godchildren"
else:
kwargs["param"] = "godfathers"
kwargs["members_set"] = set()
kwargs["api_url"] = reverse(
"api:family_graph", kwargs={"user_id": self.object.id}
)
return kwargs
class UserGodfathersTreePictureView(CanViewMixin, DetailView):
"""Display a user's tree as a picture."""
model = User
pk_url_kwarg = "user_id"
def build_complex_graph(self):
import pygraphviz as pgv
self.depth = int(self.request.GET.get("depth", 4))
if self.param == "godfathers":
self.graph = pgv.AGraph(strict=False, directed=True, rankdir="BT")
else:
self.graph = pgv.AGraph(strict=False, directed=True)
family = set()
self.level = 1
# Since the tree isn't very deep, we can build it recursively
def crawl_family(user):
if self.level > self.depth:
return
self.level += 1
for u in user.__getattribute__(self.param).all():
self.graph.add_edge(user.get_short_name(), u.get_short_name())
if u not in family:
family.add(u)
crawl_family(u)
self.level -= 1
self.graph.add_node(self.object.get_short_name())
family.add(self.object)
crawl_family(self.object)
def build_family_graph(self):
import pygraphviz as pgv
self.graph = pgv.AGraph(strict=False, directed=True)
self.graph.add_node(self.object.get_short_name())
for u in self.object.godfathers.all():
self.graph.add_edge(u.get_short_name(), self.object.get_short_name())
for u in self.object.godchildren.all():
self.graph.add_edge(self.object.get_short_name(), u.get_short_name())
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "descent" in self.request.GET:
self.param = "godchildren"
elif "ancestors" in self.request.GET:
self.param = "godfathers"
else:
self.param = "family"
if self.param == "family":
self.build_family_graph()
else:
self.build_complex_graph()
# Pimp the graph before display
self.graph.node_attr["color"] = "lightblue"
self.graph.node_attr["style"] = "filled"
main_node = self.graph.get_node(self.object.get_short_name())
main_node.attr["color"] = "sandybrown"
main_node.attr["shape"] = "rect"
if self.param == "godchildren":
self.graph.graph_attr["label"] = _("Godchildren")
elif self.param == "godfathers":
self.graph.graph_attr["label"] = _("Family")
else:
self.graph.graph_attr["label"] = _("Family")
img = self.graph.draw(format="png", prog="dot")
return HttpResponse(img, content_type="image/png")
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's stats."""