making admin work with a state, however it probably should go into a middleware instead.

This commit is contained in:
Gabor Körber 2023-12-19 21:53:35 +01:00
parent 26a1bee083
commit d89ff40d33
16 changed files with 3240 additions and 3 deletions

1
Cargo.lock generated
View File

@ -541,6 +541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80084fa3099f58b7afab51e5f92e24c2c2c68dcad26e96ad104bd6011570461d"
dependencies = [
"memo-map",
"percent-encoding",
"self_cell",
"serde",
]

View File

@ -18,7 +18,11 @@ barrel = { version = "0.7.0", optional = true, features = ["pg"] }
diesel = { version = "2.1.3", features = ["serde_json", "postgres"] }
dotenvy = "0.15.7"
mime_guess = "2.0.4"
minijinja = { version = "1.0.8", features = ["loader"] }
minijinja = { version = "1.0.8", features = [
"loader",
"builtins",
"urlencode",
] }
minijinja-autoreload = "1.0.8"
once_cell = "1.18.0"
pulldown-cmark = "0.9.3"

View File

@ -2,7 +2,7 @@ default:
@just hello
run:
@cargo run main
@cargo run --bin miniweb
bin args='':
@cargo run --bin {{args}}

35
src/admin/domain.rs Normal file
View File

@ -0,0 +1,35 @@
pub use dto::AdminApp;
pub use dto::AdminModel;
mod auth {
struct AdminUser {}
struct AdminRole {}
struct AdminGroup {}
struct AdminActionLog {}
}
mod dto {
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct AdminModel {
pub name: String,
pub object_name: String,
pub admin_url: String,
pub view_only: bool,
pub add_url: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub struct AdminApp {
pub name: String,
pub app_label: String,
pub app_url: String,
pub models: Vec<AdminModel>,
}
}

13
src/admin/mod.rs Normal file
View File

@ -0,0 +1,13 @@
use axum::{routing::get, Router};
use crate::state::AppState;
pub mod domain;
pub mod state;
pub mod views;
/*
pub fn routes() -> Router<AppState> {
Router::new().route("/", get(views::index).post(views::index_action))
}
*/

158
src/admin/state.rs Normal file
View File

@ -0,0 +1,158 @@
use crate::admin::domain::{AdminApp, AdminModel};
use axum::routing::MethodRouter;
use axum::{async_trait, response::IntoResponse};
use axum::{routing::get, Router};
use core::future::Future;
use std::collections::HashMap;
use std::sync::Arc;
pub type AdminState = Arc<AdminRegistry>;
// main registry.
#[derive(Clone)]
pub struct AdminRegistry {
base_path: String,
apps: HashMap<String, internal::DataNode>,
models: HashMap<String, internal::DataNode>,
}
impl AdminRegistry {
pub fn new(base_path: &str) -> Self {
AdminRegistry {
base_path: base_path.to_owned(),
apps: HashMap::new(),
models: HashMap::new(),
}
}
pub fn generate_router<T>(&self) -> Router<T>
where
T: Clone,
T: Send,
T: Sync,
T: 'static,
crate::service::templates::Templates: axum::extract::FromRef<T>,
AdminState: axum::extract::FromRef<T>,
{
routing::generate_routes::<T>(&self)
}
pub fn get_apps(&self) -> Vec<AdminApp> {
self.apps
.iter()
.map(|(key, node)| self.get_app(key, node))
.collect()
}
fn get_app(&self, name: &str, node: &internal::DataNode) -> AdminApp {
let label = node.label.as_ref().unwrap_or(&String::new()).clone();
AdminApp {
name: name.to_owned(),
app_label: label,
app_url: "".to_owned(),
models: vec![],
}
}
pub fn register_app(&mut self, name: &str, app_label: &str, app_url: &str) {
self.apps.insert(
name.to_owned(),
internal::DataNode {
object_name: name.to_owned(),
label: Some(app_label.to_owned()),
},
);
}
pub fn get_models(&self) -> Vec<AdminModel> {
vec![]
}
pub fn get_models_for_app(&self, name: &str) -> Vec<AdminModel> {
vec![]
}
pub fn register_model(&mut self, name: &str, config: AdminModelConfig) {}
}
// user uses this configuration object to register another model.
pub struct AdminModelConfig {
pub app: Arc<str>,
pub custom_index_handler: Option<MethodRouter>,
}
mod internal {
#[derive(Clone)]
pub struct DataNode {
pub object_name: String,
pub label: Option<String>,
}
}
mod routing {
use axum::{routing::get, Router};
pub fn generate_routes<T>(registry: &super::AdminRegistry) -> Router<T>
where
T: Clone,
T: Send,
T: Sync,
T: 'static,
crate::service::templates::Templates: axum::extract::FromRef<T>,
super::AdminState: axum::extract::FromRef<T>,
{
let mut router: Router<T> = Router::new();
let apps = registry.get_apps();
router = router.route(
&format!("/{}", registry.base_path),
get(crate::admin::views::index),
);
for app in apps {
let app_route = format!("/{}/{}", registry.base_path, app.app_url);
// Add a route for the app
router = router.route(
&app_route,
get(move || async move { format!("Admin panel for {}", app.app_label) }),
);
// Add routes for each model in the app
/*for model in data_node.models.iter() {
let model_route = format!("{}/{}", app_route, model.name);
router = router.route(
&model_route,
get(move || async move { format!("Model page for {}", model.name) }),
);
}*/
}
router
}
fn define_example_data(registry: &mut super::AdminRegistry) {
/*let models = vec![
AdminModel {
name: "User".to_owned(),
object_name: "User".to_owned(),
admin_url: "/admin/users/user".to_owned(),
view_only: false,
add_url: Some("/admin/users/user/add".to_owned()),
},
AdminModel {
name: "Group".to_owned(),
object_name: "Group".to_owned(),
admin_url: "/admin/users/group".to_owned(),
view_only: false,
add_url: None,
},
AdminModel {
name: "Permission".to_owned(),
object_name: "Permission".to_owned(),
admin_url: "/admin/users/permission".to_owned(),
view_only: true,
add_url: None,
},
];*/
registry.register_app("auth", "Authorities", "auth");
}
}

171
src/admin/views.rs Normal file
View File

@ -0,0 +1,171 @@
use std::sync::Arc;
use axum::{extract::State, response::IntoResponse, Form};
use crate::admin::domain::{AdminApp, AdminModel};
use crate::admin::state;
use crate::service::templates;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct Question {
question: String,
}
#[derive(Deserialize)]
pub struct ExampleData {
title: String,
content: String,
}
#[derive(Serialize, Deserialize)]
pub struct AdminRequest {
pub path: String,
}
#[derive(Deserialize, Serialize)]
pub struct AdminContext {
pub language_code: Option<String>,
pub language_bidi: Option<bool>,
pub user: Option<String>, // Todo: user type
pub site_url: Option<String>,
pub docsroot: Option<String>,
pub messages: Vec<String>, // Todo: message type
pub title: Option<String>,
pub subtitle: Option<String>,
pub content: String,
pub request: AdminRequest,
pub available_apps: Vec<AdminApp>,
}
impl Default for AdminContext {
fn default() -> Self {
AdminContext {
language_code: Some("en-us".to_string()), // Default language code
language_bidi: Some(false), // Default language bidi
user: None, //UserType::default(), // Assuming UserType has a Default impl
site_url: None,
docsroot: None,
messages: Vec::new(), // Empty vector for messages
title: None,
subtitle: None,
content: String::new(), // Empty string for content
available_apps: Vec::new(),
request: AdminRequest {
path: "".to_owned(),
},
}
}
}
// Index is the entry point of Admin. It should display the Dashboard to a logged in user.
pub async fn index_example(templates: State<templates::Templates>) -> impl IntoResponse {
let models = vec![
AdminModel {
name: "User".to_owned(),
object_name: "User".to_owned(),
admin_url: "/admin/users/user".to_owned(),
view_only: false,
add_url: Some("/admin/users/user/add".to_owned()),
},
AdminModel {
name: "Group".to_owned(),
object_name: "Group".to_owned(),
admin_url: "/admin/users/group".to_owned(),
view_only: false,
add_url: None,
},
AdminModel {
name: "Permission".to_owned(),
object_name: "Permission".to_owned(),
admin_url: "/admin/users/permission".to_owned(),
view_only: true,
add_url: None,
},
];
let core_app = AdminApp {
name: "Admin".to_owned(),
app_label: "admin".to_owned(),
app_url: "/admin/users/".to_owned(),
models: models,
};
templates.render_html(
"admin/index.html",
AdminContext {
available_apps: vec![core_app],
..Default::default()
},
)
}
pub async fn index(
templates: State<templates::Templates>,
administration: State<Arc<state::AdminRegistry>>,
) -> impl IntoResponse {
let models = vec![
AdminModel {
name: "User".to_owned(),
object_name: "User".to_owned(),
admin_url: "/admin/users/user".to_owned(),
view_only: false,
add_url: Some("/admin/users/user/add".to_owned()),
},
AdminModel {
name: "Group".to_owned(),
object_name: "Group".to_owned(),
admin_url: "/admin/users/group".to_owned(),
view_only: false,
add_url: None,
},
AdminModel {
name: "Permission".to_owned(),
object_name: "Permission".to_owned(),
admin_url: "/admin/users/permission".to_owned(),
view_only: true,
add_url: None,
},
];
let core_app = AdminApp {
name: "Admin".to_owned(),
app_label: "admin".to_owned(),
app_url: "/admin/users/".to_owned(),
models: models,
};
let mut available = vec![core_app];
available.extend(administration.get_apps());
templates.render_html(
"admin/index.html",
AdminContext {
available_apps: available,
..Default::default()
},
)
}
// Index Action is POST to the index site. We can anchor some general business code here.
pub async fn index_action(Form(example_data): Form<ExampleData>) -> impl IntoResponse {
"There is your answer!".to_owned()
}
// List Items renders the entire list item page.
pub async fn list_item_collection(templates: State<templates::Templates>) -> impl IntoResponse {
templates.render_html("admin/list_items.html", ())
}
// Items Action is a POST to an item list. By default these are actions, that work on a list of items as input.
pub async fn item_collection_action(Form(question): Form<Question>) -> impl IntoResponse {
"There is your answer!".to_owned()
}
// Item Details shows one single dataset.
pub async fn item_details(templates: State<templates::Templates>) -> impl IntoResponse {
templates.render_html("admin/item_detail.html", ())
}
// Item Action allows running an action on one single dataset.
pub async fn item_action(Form(question): Form<Question>) -> impl IntoResponse {
"There is your answer!".to_owned()
}

View File

@ -1,3 +1,4 @@
pub mod admin;
pub mod schema;
pub mod service;
pub mod state;

View File

@ -1,3 +1,4 @@
mod admin;
mod howto;
mod service;
mod state;
@ -8,6 +9,7 @@ use axum::{
extract::State, handler::HandlerWithoutStateExt, response::IntoResponse, routing::get, Router,
};
use std::net::SocketAddr;
use std::sync::Arc;
async fn home(templates: State<templates::Templates>) -> impl IntoResponse {
templates.render_html("index.html", ())
@ -21,12 +23,20 @@ async fn hello_world(templates: State<templates::Templates>) -> impl IntoRespons
async fn main() {
// Prepare App State
let tmpl = templates::Templates::initialize().expect("Template Engine could not be loaded.");
let state: AppState = AppState { templates: tmpl };
let mut admin = admin::state::AdminRegistry::new("admin");
admin.register_app("auth", "Authorities", "auth");
let admin_router = admin.generate_router();
let state: AppState = AppState {
templates: tmpl,
admin: Arc::new(admin),
};
// Application Route
let app = Router::new()
.route("/", get(home))
.route("/hello", get(hello_world))
.merge(admin_router)
.nest("/howto", howto::routes())
.route_service("/static/*file", handlers::static_handler.into_service())
.fallback(handlers::not_found_handler)

View File

@ -17,6 +17,12 @@ impl Templates {
let template_path = "templates";
environment.set_loader(path_loader(&template_path));
environment.add_filter("markdown", markdown);
environment.add_filter("yesno", filter_yesno);
environment.add_function("static", tpl_static);
environment.add_function("url", tpl_url);
environment.add_function("csrf_token", tpl_to_be_implemented);
environment.add_function("translate", tpl_translate);
notifier.watch_path(template_path, true);
Ok(environment)
});
@ -58,3 +64,23 @@ fn markdown(value: String) -> Value {
pulldown_cmark::html::push_html(&mut html, parser);
Value::from_safe_string(html)
}
fn filter_yesno(value: String, yesno: String) -> String {
"".into()
}
fn tpl_url(value: String) -> Value {
Value::from_safe_string(value.into())
}
fn tpl_translate(value: String) -> Value {
Value::from_safe_string(value.into())
}
fn tpl_to_be_implemented(value: String) -> Value {
Value::from_safe_string("".into())
}
fn tpl_static(value: String) -> Value {
Value::from_safe_string("".into())
}

View File

@ -1,10 +1,14 @@
use std::sync::Arc;
use axum::extract::FromRef;
use crate::admin::state::AdminRegistry;
use crate::service::templates;
#[derive(Clone)]
pub struct AppState {
pub templates: templates::Templates,
pub admin: Arc<AdminRegistry>,
}
impl FromRef<AppState> for templates::Templates {
@ -12,3 +16,9 @@ impl FromRef<AppState> for templates::Templates {
app_state.templates.clone()
}
}
impl FromRef<AppState> for Arc<AdminRegistry> {
fn from_ref(app_state: &AppState) -> Arc<AdminRegistry> {
app_state.admin.clone()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{% if app_list %}
{% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}">
<table>
<caption>
<a href="{{ app.app_url }}" class="section" title="Models in the {{ name }} application">{{ app.name }}</a>
</caption>
{% for model in app.models %}
<tr class="model-{{ model.object_name|lower }}{% if model.admin_url in request.path|urlencode %} current-model{% endif %}">
{% if model.admin_url %}
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path|urlencode %} aria-current="page"{% endif %}>{{ model.name }}</a></th>
{% else %}
<th scope="row">{{ model.name }}</th>
{% endif %}
{% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{{ translate( 'Add') }}</a></td>
{% else %}
<td></td>
{% endif %}
{% if model.admin_url and show_changelinks %}
{% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{{ translate( 'View') }}</a></td>
{% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{{ translate( 'Change') }}</a></td>
{% endif %}
{% elif show_changelinks %}
<td></td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
{% else %}
<p>{{ translate( 'You dont have permission to view or edit anything.') }}</p>
{% endif %}

127
templates/admin/base.html Normal file
View File

@ -0,0 +1,127 @@
<!DOCTYPE html>
{% set is_nav_sidebar_enabled = true %}
<html lang="{{ language_code|default("en-us") }}"
dir="{{ LANGUAGE_BIDI|yesno('rtl,ltr,auto') }}">
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{% block stylesheet %}{{ static("admin/css/base.css") }}{% endblock %}">
<link rel="stylesheet" href="/static/django_admin/css/combined.css">
{% block dark_mode_vars %}
<link rel="stylesheet" href="{{ static("admin/css/dark_mode.css") }}">
<script src="{{ static("admin/js/theme.js") }}" defer></script>
{% endblock %}
{% if not is_popup and is_nav_sidebar_enabled %}
<link rel="stylesheet" href="{{ static("admin/css/nav_sidebar.css") }}">
<script src="{{ static('admin/js/nav_sidebar.js') }}" defer></script>
{% endif %}
{% block extrastyle %}{% endblock %}
{% if LANGUAGE_BIDI %}<link rel="stylesheet" href="{% block stylesheet_rtl %}{{ static("admin/css/rtl.css") }}{% endblock %}">{% endif %}
{% block extrahead %}{% endblock %}
{% block responsive %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ static("admin/css/responsive.css") }}">
{% if LANGUAGE_BIDI %}<link rel="stylesheet" href="{{ static("admin/css/responsive_rtl.css") }}">{% endif %}
{% endblock %}
{% block blockbots %}<meta name="robots" content="NONE,NOARCHIVE">{% endblock %}
</head>
<body class="{% if is_popup %}popup {% endif %}{% block bodyclass %}{% endblock %}"
data-admin-utc-offset="{# now("Z") #}">
<a href="#content-start" class="skip-to-content-link">{{ translate('Skip to main content') }}</a>
<!-- Container -->
<div id="container">
{% if not is_popup %}
<!-- Header -->
{% block header %}
<header id="header">
<div id="branding">
{% block branding %}{% endblock %}
</div>
{% block usertools %}
{% if has_permission %}
<div id="user-tools">
{% block welcome_msg %}
{{ translate('Welcome,') }}
<strong>{{ user.get_short_name or user.get_username }}</strong>.
{% endblock %}
{% block userlinks %}
{% if site_url %}
<a href="{{ site_url }}">{{ translate('View site') }}</a> /
{% endif %}
{% if user.is_active and user.is_staff %}
{% set docsroot = url('django-admindocs-docroot') %}
{% if docsroot %}
<a href="{{ docsroot }}">{{ translate('Documentation') }}</a> /
{% endif %}
{% endif %}
{% if user.has_usable_password %}
<a href="{{ url('admin:password_change') }}">{{ translate('Change password') }}</a> /
{% endif %}
<form id="logout-form" method="post" action="{{ url('admin:logout') }}">
{{ csrf_token() }}
<button type="submit">{{ translate('Log out') }}</button>
</form>
{% include "admin/color_theme_toggle.html" %}
{% endblock %}
</div>
{% endif %}
{% endblock %}
{% block nav_global %}{% endblock %}
</header>
{% endblock %}
<!-- END Header -->
{% block nav_breadcrumbs %}
<nav aria-label="{{ translate('Breadcrumbs') }}">
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{{ url( 'admin:index') }}">{{ translate('Home') }}</a>
{% if title %} &rsaquo; {{ title }}{% endif %}
</div>
{% endblock %}
</nav>
{% endblock %}
{% endif %}
<div class="main" id="main">
{% if not is_popup and is_nav_sidebar_enabled %}
{% block nav_sidebar %}
{% include "admin/nav_sidebar.html" %}
{% endblock %}
{% endif %}
<main id="content-start" class="content" tabindex="-1">
{% block messages %}
{% if messages %}
<ul class="messagelist">{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message|capfirst }}</li>
{% endfor %}</ul>
{% endif %}
{% endblock messages %}
<!-- Content -->
<div id="content" class="{% block coltype %}colM{% endblock %}">
{% block pretitle %}{% endblock %}
{% block content_title %}{% if title %}<h1>{{ title }}</h1>{% endif %}{% endblock %}
{% block content_subtitle %}{% if subtitle %}<h2>{{ subtitle }}</h2>{% endif %}{% endblock %}
{% block content %}
{% block object_tools %}{% endblock %}
{{ content }}
{% endblock %}
{% block sidebar %}{% endblock %}
<br class="clear">
</div>
<!-- END Content -->
{% block footer %}<div id="footer"></div>{% endblock %}
</main>
</div>
</div>
<!-- END Container -->
<!-- SVGs -->
<svg xmlns="http://www.w3.org/2000/svg" class="base-svgs">
<symbol viewBox="0 0 24 24" width="1rem" height="1rem" id="icon-auto"><path d="M0 0h24v24H0z" fill="currentColor"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2V4a8 8 0 1 0 0 16z"/></symbol>
<symbol viewBox="0 0 24 24" width="1rem" height="1rem" id="icon-moon"><path d="M0 0h24v24H0z" fill="currentColor"/><path d="M10 7a7 7 0 0 0 12 4.9v.1c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2h.1A6.979 6.979 0 0 0 10 7zm-6 5a8 8 0 0 0 15.062 3.762A9 9 0 0 1 8.238 4.938 7.999 7.999 0 0 0 4 12z"/></symbol>
<symbol viewBox="0 0 24 24" width="1rem" height="1rem" id="icon-sun"><path d="M0 0h24v24H0z" fill="currentColor"/><path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-2a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85l1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"/></symbol>
</svg>
<!-- END SVGs -->
</body>
</html>

View File

@ -0,0 +1 @@
{% extends "admin/base.html" %}

View File

@ -0,0 +1,9 @@
<button class="sticky toggle-nav-sidebar" id="toggle-nav-sidebar" aria-label="{{ translate('Toggle navigation') }}"></button>
<nav class="sticky" id="nav-sidebar1" aria-label="{{ translate('Sidebar') }}">
<input type="search" id="nav-filter"
placeholder="{{ translate('Start typing to filter…') }}"
aria-label="{{ translate('Filter navigation items') }}">
{% set app_list=available_apps %}
{% set show_changelinks=false %}
{% include 'admin/app_list.html' %}
</nav>