mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-10 03:49:24 +00:00
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:
committed by
GitHub
parent
bf96d8a10c
commit
f624b7c66d
53
core/api.py
53
core/api.py
@ -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
|
||||
]
|
||||
),
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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]
|
||||
|
@ -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)) {
|
||||
|
@ -239,6 +239,7 @@ a:not(.button) {
|
||||
padding: 9px 13px;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
|
||||
&.btn-blue {
|
||||
background-color: $deepblue;
|
||||
|
267
core/static/user/js/family_graph.js
Normal file
267
core/static/user/js/family_graph.js
Normal 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;
|
||||
},
|
||||
}));
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
7
core/static/vendored/cytoscape/cytoscape-cxtmenu.min.js
vendored
Normal file
7
core/static/vendored/cytoscape/cytoscape-cxtmenu.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
core/static/vendored/cytoscape/cytoscape-klay.min.js
vendored
Normal file
7
core/static/vendored/cytoscape/cytoscape-klay.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
32
core/static/vendored/cytoscape/cytoscape.min.js
vendored
Normal file
32
core/static/vendored/cytoscape/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
core/static/vendored/cytoscape/klay.min.js
vendored
Normal file
7
core/static/vendored/cytoscape/klay.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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
0
core/tests/__init__.py
Normal file
187
core/tests/test_family.py
Normal file
187
core/tests/test_family.py
Normal 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)
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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."""
|
||||
|
||||
|
Reference in New Issue
Block a user