3 Commits

Author SHA1 Message Date
Sli
d1d9d5b44e poc: improve galaxy rendering 2025-09-10 23:52:33 +02:00
Sli
3f14343b99 poc: improve forces on galaxy simulation 2025-09-10 11:34:56 +02:00
Sli
8e4d0da62e poc: 2D graph for galaxy 2025-09-10 00:37:48 +02:00
13 changed files with 208 additions and 295 deletions

View File

@@ -25,7 +25,6 @@ from core.schemas import (
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
UserSchema,
)
from core.templatetags.renderer import markdown
@@ -70,22 +69,16 @@ class MailingListController(ControllerBase):
return data
@api_controller("/user")
@api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
"""Fetch a single user"""
return self.get_object_or_exception(User, id=user_id)
@route.get(
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):

View File

@@ -94,11 +94,7 @@ class Command(BaseCommand):
username=self.faker.user_name(),
first_name=self.faker.first_name(),
last_name=self.faker.last_name(),
date_of_birth=(
None
if random.random() < 0.2
else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
email=self.faker.email(),
phone=self.faker.phone_number(),
address=self.faker.address(),

View File

@@ -34,22 +34,6 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"]
class UserSchema(ModelSchema):
class Meta:
model = User
fields = [
"id",
"nick_name",
"first_name",
"last_name",
"date_of_birth",
"email",
"role",
"quote",
"promo",
]
class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile"""

View File

@@ -1,138 +0,0 @@
import { default as ForceGraph3D } from "3d-force-graph";
import { forceX, forceY, forceZ } from "d3-force-3d";
// biome-ignore lint/style/noNamespaceImport: This is how it should be imported
import * as Three from "three";
import SpriteText from "three-spritetext";
/**
* @typedef GalaxyConfig
* @property {number} nodeId id of the current user node
* @property {string} dataUrl url to fetch the galaxy data from
**/
/**
* Load the galaxy of an user
* @param {GalaxyConfig} config
**/
window.loadGalaxy = (config) => {
window.getNodeFromId = (id) => {
return Graph.graphData().nodes.find((n) => n.id === id);
};
window.getLinksFromNodeId = (id) => {
return Graph.graphData().links.filter(
(l) => l.source.id === id || l.target.id === id,
);
};
window.focusNode = (node) => {
highlightNodes.clear();
highlightLinks.clear();
hoverNode = node || null;
if (node) {
// collect neighbors and links for highlighting
for (const link of window.getLinksFromNodeId(node.id)) {
highlightLinks.add(link);
highlightNodes.add(link.source);
highlightNodes.add(link.target);
}
}
// refresh node and link display
Graph.nodeThreeObject(Graph.nodeThreeObject())
.linkWidth(Graph.linkWidth())
.linkDirectionalParticles(Graph.linkDirectionalParticles());
// Aim at node from outside it
const distance = 42;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
const newPos =
node.x || node.y || node.z
? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }
: { x: 0, y: 0, z: distance }; // special case if node is in (0,0,0)
Graph.cameraPosition(
newPos, // new position
node, // lookAt ({ x, y, z })
3000, // ms transition duration
);
};
const highlightNodes = new Set();
const highlightLinks = new Set();
let hoverNode = null;
const grpahDiv = document.getElementById("3d-graph");
const Graph = ForceGraph3D();
Graph(grpahDiv);
Graph.jsonUrl(config.dataUrl)
.width(
grpahDiv.parentElement.clientWidth > 1200
? 1200
: grpahDiv.parentElement.clientWidth,
) // Not perfect at all. JS-fu master from the future, please fix this :-)
.height(1000)
.enableNodeDrag(false) // allow easier navigation
.onNodeClick((node) => {
const camera = Graph.cameraPosition();
const distance = Math.sqrt(
(node.x - camera.x) ** 2 + (node.y - camera.y) ** 2 + (node.z - camera.z) ** 2,
);
if (distance < 120 || highlightNodes.has(node)) {
window.focusNode(node);
}
})
.linkWidth((link) => (highlightLinks.has(link) ? 0.4 : 0.0))
.linkColor((link) =>
highlightLinks.has(link) ? "rgba(255,160,0,1)" : "rgba(128,255,255,0.6)",
)
.linkVisibility((link) => highlightLinks.has(link))
.nodeVisibility((node) => highlightNodes.has(node) || node.mass > 4)
// .linkDirectionalParticles(link => highlightLinks.has(link) ? 3 : 1) // kinda buggy for now, and slows this a bit, but would be great to help visualize lanes
.linkDirectionalParticleWidth(0.2)
.linkDirectionalParticleSpeed(-0.006)
.nodeThreeObject((node) => {
const sprite = new SpriteText(node.name);
sprite.material.depthWrite = false; // make sprite background transparent
sprite.color = highlightNodes.has(node)
? node === hoverNode
? "rgba(200,0,0,1)"
: "rgba(255,160,0,0.8)"
: "rgba(0,255,255,0.2)";
sprite.textHeight = 2;
sprite.center = new Three.Vector2(1.2, 0.5);
return sprite;
})
.onEngineStop(() => {
window.focusNode(window.getNodeFromId(config.nodeId));
Graph.onEngineStop(() => {
/* nope */
}); // don't call ourselves in a loop while moving the focus
});
// Set distance between stars
Graph.d3Force("link").distance((link) => link.value);
// Set high masses nearer the center of the galaxy
// TODO: quick and dirty strength computation, this will need tuning.
Graph.d3Force(
"positionX",
forceX().strength((node) => {
return 1 - 1 / node.mass;
}),
);
Graph.d3Force(
"positionY",
forceY().strength((node) => {
return 1 - 1 / node.mass;
}),
);
Graph.d3Force(
"positionZ",
forceZ().strength((node) => {
return 1 - 1 / node.mass;
}),
);
};

View File

@@ -0,0 +1,137 @@
import { exportToHtml } from "#core:utils/globals";
import cytoscape from "cytoscape";
import d3Force, { type D3ForceLayoutOptions } from "cytoscape-d3-force";
cytoscape.use(d3Force);
interface GalaxyConfig {
nodeId: number;
dataUrl: string;
}
async function getGraphData(dataUrl: string) {
const response = await fetch(dataUrl);
if (!response.ok) {
return [];
}
const content = await response.json();
const nodes = content.nodes.map((node, i) => {
return {
group: "nodes",
data: {
id: node.id,
name: node.name,
mass: node.mass,
},
};
});
const edges = content.links.map((link) => {
return {
group: "edges",
data: {
id: `edge_${link.source}_${link.value}`,
source: link.source,
target: link.target,
value: link.value,
},
};
});
return { nodes: nodes, edges: edges };
}
exportToHtml("loadGalaxy", async (config: GalaxyConfig) => {
const graphDiv = document.getElementById("3d-graph");
const elements = await getGraphData(config.dataUrl);
const cy = cytoscape({
container: graphDiv,
elements: elements,
style: [
{
selector: "node",
style: {
label: "data(name)",
"background-color": "red",
},
},
{
selector: ".focused",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "black",
"target-arrow-color": "black",
"line-color": "black",
},
},
{
selector: "edge",
style: {
width: 0.1,
},
},
{
selector: ".direct",
style: {
width: "5px",
"line-color": "red",
},
},
],
layout: {
name: "d3-force",
animate: true,
fit: false,
ungrabifyWhileSimulating: true,
fixedAfterDragging: true,
linkId: (node) => {
return node.id;
},
linkDistance: (link) => {
return elements.nodes.length * 10;
},
linkStrength: (link) => {
return 1 / Math.max(1, link?.value);
},
linkIterations: 10,
manyBodyStrength: (node) => {
return node?.mass;
},
// manyBodyDistanceMin: 500,
collideRadius: () => {
return 50;
},
ready: (e) => {
// Center on current user node at the start of the simulation
// Color all direct paths from that citizen to it's neighbor
const citizen = e.cy.nodes(`#${config.nodeId}`)[0];
citizen.addClass("focused");
citizen.connectedEdges().addClass("direct");
e.cy.center(citizen);
},
tick: () => {
// Center on current user node during simulation
const citizen = cy.nodes(`#${config.nodeId}`)[0];
cy.center(citizen);
},
stop: (e) => {
// Disable user grabbing of nodes
// This has to be disabled after the simulation is done
// Otherwise the simulation can't move nodes
e.cy.autolock(true);
},
} as D3ForceLayoutOptions,
});
});

View File

@@ -5,14 +5,14 @@
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static('bundled/galaxy/galaxy-index.js') }}"></script>
<script type="module" src="{{ static('bundled/galaxy/galaxy-index.ts') }}"></script>
{% endblock %}
{% block content %}
{% if object.current_star %}
<div style="display: flex; flex-wrap: wrap;">
<div id="3d-graph"></div>
<div style="width: 100%; height: 70vh; display: block" id="3d-graph"></div>
<div style="margin: 1em;">
<p><a onclick="window.focusNode(window.getNodeFromId({{ object.id }}))">Reset on {{ object.get_display_name() }}</a></p>

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-02 15:56+0200\n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -5135,10 +5135,6 @@ msgstr "Tee-shirt AE"
msgid "A user with that email address already exists"
msgstr "Un utilisateur avec cette adresse email existe déjà"
#: subscription/forms.py
msgid "This user didn't fill its birthdate yet."
msgstr "Cet utilisateur n'a pas encore renseigné sa date de naissance"
#: subscription/models.py
msgid "Bad subscription type"
msgstr "Mauvais type de cotisation"
@@ -5178,7 +5174,7 @@ msgid ""
"%(user)s received its new %(type)s subscription. It will be active until "
"%(end)s included."
msgstr ""
"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sera active jusqu'au "
"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au "
"%(end)s inclu."
#: subscription/templates/subscription/fragments/creation_success.jinja

59
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-d3-force": "^1.1.4",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
@@ -46,6 +47,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-d3-force": "^1.0.0",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
@@ -2873,6 +2875,16 @@
"@types/cytoscape": "*"
}
},
"node_modules/@types/cytoscape-d3-force": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/cytoscape-d3-force/-/cytoscape-d3-force-1.0.0.tgz",
"integrity": "sha512-1eRd9xr/DvJ4MIA5lCEG8DMX2Ha87qAbpP7irpuKZun0ZCBQPpoOBo9mPl0WrkJbXH+hHwG8s3E2CpUz3HxLrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "^3.0.9"
}
},
"node_modules/@types/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
@@ -3530,6 +3542,18 @@
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-d3-force": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-d3-force/-/cytoscape-d3-force-1.1.4.tgz",
"integrity": "sha512-8NjI/yEoB3YqVsdf7ud7Oh8Kyi+C9Lhh1fICmtemIo6EC1ZUtm8KcPNLkQySYO8nRS2mQKj5eVdCr7W0L8ONoQ==",
"license": "MIT",
"dependencies": {
"d3-force": "^2.0.1"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
@@ -3578,6 +3602,17 @@
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz",
"integrity": "sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-quadtree": "1 - 2",
"d3-timer": "1 - 2"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
@@ -3594,6 +3629,24 @@
"node": ">=12"
}
},
"node_modules/d3-force/node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-force/node_modules/d3-quadtree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz",
"integrity": "sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==",
"license": "BSD-3-Clause"
},
"node_modules/d3-force/node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
@@ -5737,9 +5790,9 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -31,6 +31,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-d3-force": "^1.0.0",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
@@ -56,6 +57,7 @@
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-d3-force": "^1.1.4",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",

View File

@@ -23,8 +23,8 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs):
initial = initial or {}
def __init__(self, *args, **kwargs):
initial = kwargs.pop("initial", {})
if "subscription_type" not in initial:
initial["subscription_type"] = "deux-semestres"
if "payment_method" not in initial:
@@ -131,57 +131,8 @@ class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.html"
required_css_class = "required"
birthdate = forms.fields_for_model(
User,
["date_of_birth"],
widgets={"date_of_birth": SelectDate(attrs={"hidden": True})},
help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")},
)["date_of_birth"]
class Meta:
model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}
field_order = [
"member",
"birthdate",
"subscription_type",
"payment_method",
"location",
]
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
self.fields["birthdate"].required = True
if not initial:
return
member: str | None = initial.get("member")
if member and member.isdigit():
member: User | None = User.objects.filter(id=int(member)).first()
else:
member = None
if member and member.date_of_birth:
# if there is an initial member with a birthdate,
# there is no need to ask this to the user
self.fields["birthdate"].initial = member.date_of_birth
elif member:
# if there is an initial member without a birthdate,
# then the field must be displayed
self.fields["birthdate"].widget.attrs.update({"hidden": False})
# if there is no initial member, it means that it will be
# dynamically selected using the AutoCompleteSelectUser widget.
# JS will take care of un-hiding the field if necessary
def save(self, *args, **kwargs):
if self.errors:
return super().save(*args, **kwargs)
if (
self.cleaned_data["birthdate"] is not None
and self.instance.member.date_of_birth is None
):
self.instance.member.date_of_birth = self.cleaned_data["birthdate"]
self.instance.member.save()
return super().save(*args, **kwargs)

View File

@@ -1,5 +1,3 @@
import { userFetchUser } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
@@ -14,24 +12,13 @@ document.addEventListener("alpine:init", () => {
},
async loadProfile(userId: number) {
const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
if (!Number.isInteger(userId)) {
this.profileFragment = "";
birthdayInput.hidden = true;
return;
}
this.loading = true;
const [miniProfile, userInfos] = await Promise.all([
fetch(`/user/${userId}/mini/`),
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
birthdayInput.value = userInfos.data.date_of_birth;
birthdayInput.hidden = userInfos.data.date_of_birth !== null;
const response = await fetch(`/user/${userId}/mini/`);
this.profileFragment = await response.text();
this.loading = false;
},
}));

View File

@@ -1,14 +1,4 @@
#subscription-form form {
margin-top: 0;
.form-content {
margin-top: 0;
}
fieldset p:first-of-type, & > p:first-of-type {
margin-top: 0;
}
.form-content.existing-user {
max-height: 100%;
display: flex;
@@ -23,11 +13,6 @@
* then display the user profile right in the middle of the remaining space. */
fieldset {
flex: 0 1 auto;
p:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group
display: none;
}
}
#subscription-form-user-mini-profile {

View File

@@ -1,6 +1,6 @@
"""Tests focused on testing subscription creation"""
from datetime import date, timedelta
from datetime import timedelta
from typing import Callable
import pytest
@@ -31,26 +31,6 @@ def test_form_existing_user_valid(
):
"""Test `SubscriptionExistingUserForm`"""
user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14)
user.save()
data = {
"member": user,
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
form.save()
user.refresh_from_db()
assert user.is_subscribed
@pytest.mark.django_db
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None)
data = {
"member": user,
"subscription_type": "deux-semestres",
@@ -58,15 +38,11 @@ def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert not form.is_valid()
data |= {"birthdate": date(year=1967, month=3, day=14)}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
form.save()
user.refresh_from_db()
assert user.is_subscribed
assert user.date_of_birth == date(year=1967, month=3, day=14)
@pytest.mark.django_db
@@ -156,14 +132,6 @@ def test_page_access(
assert res.status_code == status_code
@pytest.mark.django_db
def test_page_access_with_get_data(client: Client):
user = old_subscriber_user.make()
client.force_login(baker.make(User, is_superuser=True))
res = client.get(reverse("subscription:subscription", query={"member": user.id}))
assert res.status_code == 200
@pytest.mark.django_db
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login(
@@ -172,12 +140,11 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
user_permissions=Permission.objects.filter(codename="add_subscription"),
)
)
user = old_subscriber_user.make(date_of_birth=date(year=1967, month=3, day=14))
user = old_subscriber_user.make()
response = client.post(
reverse("subscription:fragment-existing-user"),
{
"member": user.id,
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],