1use alembic_core::{FieldFormat, FieldType, Inventory, Schema, TypeName, TypeSchema};
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::Path;
8
9const GENERATED_MODELS: &str = "generated_models.py";
10const GENERATED_ADMIN: &str = "generated_admin.py";
11const GENERATED_SERIALIZERS: &str = "generated_serializers.py";
12const GENERATED_VIEWS: &str = "generated_views.py";
13const GENERATED_URLS: &str = "generated_urls.py";
14const USER_MODELS: &str = "models.py";
15const USER_ADMIN: &str = "admin.py";
16const USER_SERIALIZERS: &str = "serializers.py";
17const USER_VIEWS: &str = "views.py";
18const USER_URLS: &str = "urls.py";
19const USER_EXTENSIONS: &str = "extensions.py";
20
21const MODELS_TEMPLATE: &str = include_str!("../templates/models.py.tpl");
22const ADMIN_TEMPLATE: &str = include_str!("../templates/admin.py.tpl");
23const SERIALIZERS_TEMPLATE: &str = include_str!("../templates/serializers.py.tpl");
24const VIEWS_TEMPLATE: &str = include_str!("../templates/views.py.tpl");
25const URLS_TEMPLATE: &str = include_str!("../templates/urls.py.tpl");
26const ADMIN_SEARCH_FIELDS: &[&str] = &["key", "uid"];
27
28#[derive(Debug)]
29struct ModelSpec {
30 class_name: String,
31 fields: Vec<FieldSpec>,
32 key_fields: Vec<String>,
33 has_validators: bool,
34}
35
36#[derive(Debug, Clone)]
37struct FieldSpec {
38 name: String,
39 field_type: DjangoFieldType,
40 required: bool,
41 nullable: bool,
42 choices: Option<Vec<String>>,
43 validators: Vec<String>,
44}
45
46#[derive(Debug, Clone)]
47enum DjangoFieldType {
48 Char,
49 Text,
50 Integer,
51 Float,
52 Boolean,
53 Uuid,
54 Date,
55 DateTime,
56 Time,
57 Json,
58 Slug,
59 IpAddress,
60 ForeignKey { target: String },
61 ManyToMany { target: String },
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DjangoEmitOptions {
66 pub emit_admin: bool,
67}
68
69impl Default for DjangoEmitOptions {
70 fn default() -> Self {
71 Self { emit_admin: true }
72 }
73}
74
75pub fn emit_django_app(
76 app_dir: &Path,
77 inventory: &Inventory,
78 options: DjangoEmitOptions,
79) -> Result<()> {
80 fs::create_dir_all(app_dir)?;
81 let app_name = app_dir
82 .file_name()
83 .and_then(|name| name.to_str())
84 .unwrap_or("alembic_app");
85
86 let types = schema_types(&inventory.schema);
87 let models: Vec<ModelSpec> = types
88 .into_iter()
89 .map(|(name, schema)| model_spec_from_schema(&name, &schema))
90 .collect();
91
92 let rendered = render_files(&models, app_name, options.emit_admin);
93 fs::write(app_dir.join(GENERATED_MODELS), rendered.models)?;
94 if let Some(admin) = rendered.admin {
95 fs::write(app_dir.join(GENERATED_ADMIN), admin)?;
96 }
97 fs::write(app_dir.join(GENERATED_SERIALIZERS), rendered.serializers)?;
98 fs::write(app_dir.join(GENERATED_VIEWS), rendered.views)?;
99 fs::write(app_dir.join(GENERATED_URLS), rendered.urls)?;
100
101 write_user_file(
102 app_dir.join(USER_MODELS),
103 user_models_stub(),
104 &[default_models_stub()],
105 )?;
106 if options.emit_admin {
107 write_user_file(
108 app_dir.join(USER_ADMIN),
109 user_admin_stub(),
110 &[default_admin_stub()],
111 )?;
112 }
113 write_user_file(app_dir.join(USER_SERIALIZERS), user_serializers_stub(), &[])?;
114 write_user_file(
115 app_dir.join(USER_VIEWS),
116 user_views_stub(),
117 &[default_views_stub()],
118 )?;
119 write_user_file(app_dir.join(USER_URLS), user_urls_stub(), &[])?;
120 write_if_missing(app_dir.join(USER_EXTENSIONS), user_extensions_stub())?;
121
122 Ok(())
123}
124
125fn schema_types(schema: &Schema) -> Vec<(TypeName, TypeSchema)> {
126 let mut types: Vec<(String, TypeSchema)> = schema
127 .types
128 .iter()
129 .map(|(name, schema)| (name.clone(), schema.clone()))
130 .collect();
131 types.sort_by(|a, b| a.0.cmp(&b.0));
132 types
133 .into_iter()
134 .map(|(name, schema)| (TypeName::new(name), schema))
135 .collect()
136}
137
138fn model_spec_from_schema(type_name: &TypeName, schema: &TypeSchema) -> ModelSpec {
139 let class_name = class_name_for_type(type_name.as_str());
140 let mut fields = Vec::new();
141 let mut key_fields = Vec::new();
142 let mut has_validators = false;
143
144 for (field, field_schema) in schema.key.iter() {
145 let spec = field_spec_from_schema(field, field_schema, true);
146 if !spec.validators.is_empty() {
147 has_validators = true;
148 }
149 key_fields.push(field.to_string());
150 fields.push(spec);
151 }
152
153 for (field, field_schema) in schema.fields.iter() {
154 if schema.key.contains_key(field) {
155 continue;
156 }
157 let spec = field_spec_from_schema(field, field_schema, false);
158 if !spec.validators.is_empty() {
159 has_validators = true;
160 }
161 fields.push(spec);
162 }
163
164 ModelSpec {
165 class_name,
166 fields,
167 key_fields,
168 has_validators,
169 }
170}
171
172struct DjangoFiles {
173 models: String,
174 admin: Option<String>,
175 serializers: String,
176 views: String,
177 urls: String,
178}
179
180fn render_files(models: &[ModelSpec], app_name: &str, emit_admin: bool) -> DjangoFiles {
181 let model_names: Vec<String> = models.iter().map(|m| m.class_name.clone()).collect();
182 let model_import = import_line("from .generated_models import ", &model_names);
183 let serializer_names: Vec<String> = model_names
184 .iter()
185 .map(|name| format!("{name}Serializer"))
186 .collect();
187 let serializer_import = import_line("from .generated_serializers import ", &serializer_names);
188 let view_names: Vec<String> = model_names
189 .iter()
190 .map(|name| format!("{name}ViewSet"))
191 .collect();
192 let view_import = import_line("from .generated_views import ", &view_names);
193
194 let models_block = render_models_block(models);
195 let validators_import = if models.iter().any(|m| m.has_validators) {
196 "from django.core.validators import RegexValidator"
197 } else {
198 ""
199 };
200 let admins_block = if emit_admin {
201 render_admins_block(models)
202 } else {
203 String::new()
204 };
205 let serializers_block = render_serializers_block(models);
206 let views_block = render_views_block(models);
207 let routes_block = render_routes_block(models);
208
209 let models = render_template(
210 MODELS_TEMPLATE,
211 &[
212 ("validators_import", validators_import.to_string()),
213 ("models", models_block),
214 ],
215 );
216 let admin = if emit_admin {
217 Some(render_template(
218 ADMIN_TEMPLATE,
219 &[
220 ("model_import", model_import.clone()),
221 ("admins", admins_block),
222 ],
223 ))
224 } else {
225 None
226 };
227 let serializers = render_template(
228 SERIALIZERS_TEMPLATE,
229 &[
230 ("model_import", model_import.clone()),
231 ("serializers", serializers_block),
232 ],
233 );
234 let views = render_template(
235 VIEWS_TEMPLATE,
236 &[
237 ("model_import", model_import),
238 ("serializer_import", serializer_import),
239 ("views", views_block),
240 ],
241 );
242 let urls = render_template(
243 URLS_TEMPLATE,
244 &[
245 ("view_import", view_import),
246 ("routes", routes_block),
247 ("app_name", app_name.to_string()),
248 ],
249 );
250
251 DjangoFiles {
252 models,
253 admin,
254 serializers,
255 views,
256 urls,
257 }
258}
259
260fn render_field(field: &FieldSpec) -> String {
261 let mut args = Vec::new();
262 if let Some(choices) = &field.choices {
263 let choice_items = choices
264 .iter()
265 .map(|value| format!("(\"{value}\", \"{value}\")"))
266 .collect::<Vec<_>>()
267 .join(", ");
268 args.push(format!("choices=[{choice_items}]"));
269 }
270 if !field.validators.is_empty() {
271 let validators = field.validators.join(", ");
272 args.push(format!("validators=[{validators}]"));
273 }
274 if !field.required {
275 args.push("blank=True".to_string());
276 }
277 if field.nullable {
278 args.push("null=True".to_string());
279 }
280 if matches!(field.field_type, DjangoFieldType::IpAddress)
281 && args.iter().any(|arg| arg == "blank=True")
282 && !args.iter().any(|arg| arg == "null=True")
283 {
284 args.push("null=True".to_string());
285 }
286
287 let args_str = args.join(", ");
288
289 match &field.field_type {
290 DjangoFieldType::Char => {
291 if args_str.is_empty() {
292 format!("{} = models.CharField(max_length=255)", field.name)
293 } else {
294 format!(
295 "{} = models.CharField(max_length=255, {})",
296 field.name, args_str
297 )
298 }
299 }
300 DjangoFieldType::Text => format!("{} = models.TextField({})", field.name, args_str),
301 DjangoFieldType::Integer => format!("{} = models.IntegerField({})", field.name, args_str),
302 DjangoFieldType::Float => format!("{} = models.FloatField({})", field.name, args_str),
303 DjangoFieldType::Boolean => format!("{} = models.BooleanField({})", field.name, args_str),
304 DjangoFieldType::Uuid => format!("{} = models.UUIDField({})", field.name, args_str),
305 DjangoFieldType::Date => format!("{} = models.DateField({})", field.name, args_str),
306 DjangoFieldType::DateTime => format!("{} = models.DateTimeField({})", field.name, args_str),
307 DjangoFieldType::Time => format!("{} = models.TimeField({})", field.name, args_str),
308 DjangoFieldType::Json => format!("{} = models.JSONField({})", field.name, args_str),
309 DjangoFieldType::Slug => format!("{} = models.SlugField({})", field.name, args_str),
310 DjangoFieldType::IpAddress => {
311 format!(
312 "{} = models.GenericIPAddressField({})",
313 field.name, args_str
314 )
315 }
316 DjangoFieldType::ForeignKey { target } => {
317 let mut fk_args = vec![
318 format!("\"{}\"", target),
319 "on_delete=models.PROTECT".to_string(),
320 ];
321 fk_args.extend(args);
322 format!("{} = models.ForeignKey({})", field.name, fk_args.join(", "))
323 }
324 DjangoFieldType::ManyToMany { target } => {
325 let mut m2m_args = vec![format!("\"{}\"", target)];
326 m2m_args.extend(args.into_iter().filter(|arg| arg != "null=True"));
327 format!(
328 "{} = models.ManyToManyField({})",
329 field.name,
330 m2m_args.join(", ")
331 )
332 }
333 }
334}
335
336fn render_models_block(models: &[ModelSpec]) -> String {
337 models
338 .iter()
339 .map(render_model_block)
340 .collect::<Vec<String>>()
341 .join("\n")
342}
343
344fn render_model_block(model: &ModelSpec) -> String {
345 let mut fields = Vec::with_capacity(model.fields.len() + 3);
346 fields.push("uid = models.UUIDField(primary_key=True, editable=False)".to_string());
347 fields.push("key = models.TextField()".to_string());
348 fields.push("attrs = models.JSONField(default=dict, blank=True)".to_string());
349 for field in &model.fields {
350 fields.push(render_field(field));
351 }
352
353 let mut lines = Vec::new();
354 lines.push(format!("class {}(models.Model):", model.class_name));
355 lines.push(format!(" {}", fields.join("\n ")));
356
357 if !model.key_fields.is_empty() {
358 let unique_fields = model
359 .key_fields
360 .iter()
361 .map(|field| format!("\"{field}\""))
362 .collect::<Vec<_>>()
363 .join(", ");
364 lines.push("".to_string());
365 lines.push(" class Meta:".to_string());
366 lines.push(format!(
367 " constraints = [models.UniqueConstraint(fields=[{unique_fields}], name=\"{}_key\")]",
368 model.class_name.to_lowercase()
369 ));
370 }
371
372 lines.join("\n") + "\n"
373}
374
375fn render_admins_block(models: &[ModelSpec]) -> String {
376 models
377 .iter()
378 .map(render_admin_block)
379 .collect::<Vec<String>>()
380 .join("\n")
381}
382
383fn render_admin_block(model: &ModelSpec) -> String {
384 let list_display = admin_list_display(model);
385 let list_filter = admin_list_filter(model);
386 let mut lines = vec![
387 format!(
388 "@admin.register({})\nclass {}Admin(admin.ModelAdmin):",
389 model.class_name, model.class_name
390 ),
391 format!(" list_display = [{}]", join_quoted(&list_display)),
392 format!(" search_fields = [{}]", join_quoted(ADMIN_SEARCH_FIELDS)),
393 ];
394 if !list_filter.is_empty() {
395 lines.push(format!(" list_filter = [{}]", join_quoted(&list_filter)));
396 }
397 lines.join("\n")
398}
399
400fn render_serializers_block(models: &[ModelSpec]) -> String {
401 models
402 .iter()
403 .map(render_serializer_block)
404 .collect::<Vec<String>>()
405 .join("\n")
406}
407
408fn render_serializer_block(model: &ModelSpec) -> String {
409 let fields = serializer_fields(model);
410 format!(
411 "class {}Serializer(serializers.ModelSerializer):\n class Meta:\n model = {}\n fields = [{}]\n",
412 model.class_name,
413 model.class_name,
414 join_quoted(&fields)
415 )
416}
417
418fn render_views_block(models: &[ModelSpec]) -> String {
419 models
420 .iter()
421 .map(render_view_block)
422 .collect::<Vec<String>>()
423 .join("\n")
424}
425
426fn render_view_block(model: &ModelSpec) -> String {
427 let list_display = admin_list_display(model);
428 let search_fields = admin_search_fields_for_model(model);
429 format!(
430 "class {}ViewSet(viewsets.ModelViewSet):\n queryset = {}.objects.all()\n serializer_class = {}Serializer\n filterset_fields = [{}]\n search_fields = [{}]\n ordering_fields = [{}]\n",
431 model.class_name,
432 model.class_name,
433 model.class_name,
434 join_quoted(&list_display),
435 join_quoted(&search_fields),
436 join_quoted(&list_display)
437 )
438}
439
440fn render_routes_block(models: &[ModelSpec]) -> String {
441 models
442 .iter()
443 .map(|model| {
444 let endpoint = pluralize(model.class_name.to_lowercase().as_str());
445 format!(
446 "router.register(\"{endpoint}\", {}ViewSet)",
447 model.class_name
448 )
449 })
450 .collect::<Vec<String>>()
451 .join("\n")
452}
453
454fn field_name(field: &FieldSpec) -> &str {
455 field.name.as_str()
456}
457
458fn join_quoted(fields: &[&str]) -> String {
459 fields
460 .iter()
461 .map(|field| format!("\"{field}\""))
462 .collect::<Vec<String>>()
463 .join(", ")
464}
465
466fn import_line<T: AsRef<str>>(prefix: &str, names: &[T]) -> String {
467 if names.is_empty() {
468 String::new()
469 } else {
470 format!(
471 "{prefix}{}",
472 names
473 .iter()
474 .map(|name| name.as_ref())
475 .collect::<Vec<&str>>()
476 .join(", ")
477 )
478 }
479}
480
481fn admin_list_display(model: &ModelSpec) -> Vec<&str> {
482 let mut fields = vec!["key", "uid"];
483 for field in &model.fields {
484 fields.push(field_name(field));
485 }
486 fields
487}
488
489fn admin_search_fields_for_model(model: &ModelSpec) -> Vec<&str> {
490 let mut fields = vec!["key"];
491 for field in &model.fields {
492 match field.field_type {
493 DjangoFieldType::Char
494 | DjangoFieldType::Text
495 | DjangoFieldType::Slug
496 | DjangoFieldType::Uuid
497 | DjangoFieldType::IpAddress => fields.push(field_name(field)),
498 _ => {}
499 }
500 }
501 fields
502}
503
504fn admin_list_filter(model: &ModelSpec) -> Vec<&str> {
505 let mut fields = Vec::new();
506 for field in &model.fields {
507 match field.field_type {
508 DjangoFieldType::Boolean => fields.push(field_name(field)),
509 _ => {
510 if field.name == "status" {
511 fields.push(field_name(field));
512 }
513 }
514 }
515 }
516 fields
517}
518
519fn serializer_fields(model: &ModelSpec) -> Vec<&str> {
520 let mut fields = vec!["uid", "key", "attrs"];
521 for field in &model.fields {
522 fields.push(field_name(field));
523 }
524 fields
525}
526
527fn class_name_for_type(type_name: &str) -> String {
528 type_name
529 .split('.')
530 .map(|segment| {
531 segment
532 .split('_')
533 .filter(|s| !s.is_empty())
534 .map(|part| {
535 let mut chars = part.chars();
536 match chars.next() {
537 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
538 None => String::new(),
539 }
540 })
541 .collect::<String>()
542 })
543 .collect::<String>()
544}
545
546fn pluralize(name: &str) -> String {
547 if name.ends_with('s') {
548 format!("{name}es")
549 } else if let Some(stripped) = name.strip_suffix('y') {
550 format!("{}ies", stripped)
551 } else {
552 format!("{name}s")
553 }
554}
555
556fn field_spec_from_schema(
557 name: &str,
558 schema: &alembic_core::FieldSchema,
559 is_key: bool,
560) -> FieldSpec {
561 let mut validators = Vec::new();
562 let mut choices = None;
563
564 if let Some(format) = &schema.format {
565 validators.push(format_validator(format));
566 }
567 if let Some(pattern) = &schema.pattern {
568 validators.push(format!("RegexValidator(r\"{pattern}\")"));
569 }
570
571 let field_type = match &schema.r#type {
572 FieldType::String => DjangoFieldType::Char,
573 FieldType::Text => DjangoFieldType::Text,
574 FieldType::Int => DjangoFieldType::Integer,
575 FieldType::Float => DjangoFieldType::Float,
576 FieldType::Bool => DjangoFieldType::Boolean,
577 FieldType::Uuid => DjangoFieldType::Uuid,
578 FieldType::Date => DjangoFieldType::Date,
579 FieldType::Datetime => DjangoFieldType::DateTime,
580 FieldType::Time => DjangoFieldType::Time,
581 FieldType::Json => DjangoFieldType::Json,
582 FieldType::IpAddress => DjangoFieldType::IpAddress,
583 FieldType::Cidr | FieldType::Prefix | FieldType::Mac => {
584 validators.push(format_validator(&format_for_field_type(&schema.r#type)));
585 DjangoFieldType::Char
586 }
587 FieldType::Slug => DjangoFieldType::Slug,
588 FieldType::Enum { values } => {
589 choices = Some(values.clone());
590 DjangoFieldType::Char
591 }
592 FieldType::List { .. } | FieldType::Map { .. } => DjangoFieldType::Json,
593 FieldType::Ref { target } => DjangoFieldType::ForeignKey {
594 target: class_name_for_type(target),
595 },
596 FieldType::ListRef { target } => DjangoFieldType::ManyToMany {
597 target: class_name_for_type(target),
598 },
599 };
600
601 let required = schema.required || is_key;
602 let nullable = schema.nullable && !is_key;
603
604 FieldSpec {
605 name: name.to_string(),
606 field_type,
607 required,
608 nullable,
609 choices,
610 validators,
611 }
612}
613
614fn format_for_field_type(field_type: &FieldType) -> FieldFormat {
615 match field_type {
616 FieldType::IpAddress => FieldFormat::IpAddress,
617 FieldType::Cidr => FieldFormat::Cidr,
618 FieldType::Prefix => FieldFormat::Prefix,
619 FieldType::Mac => FieldFormat::Mac,
620 FieldType::Uuid => FieldFormat::Uuid,
621 FieldType::Slug => FieldFormat::Slug,
622 _ => FieldFormat::Slug,
623 }
624}
625
626fn format_validator(format: &FieldFormat) -> String {
627 match format {
628 FieldFormat::Slug => "RegexValidator(r\"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$\")".to_string(),
629 FieldFormat::IpAddress => {
630 "RegexValidator(r\"^([0-9]{1,3}\\.){3}[0-9]{1,3}$|^[0-9a-fA-F:]+$\")".to_string()
631 }
632 FieldFormat::Cidr | FieldFormat::Prefix => {
633 "RegexValidator(r\"^[0-9a-fA-F:\\./]+$\")".to_string()
634 }
635 FieldFormat::Mac => {
636 "RegexValidator(r\"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$\")".to_string()
637 }
638 FieldFormat::Uuid => {
639 "RegexValidator(r\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\")".to_string()
640 }
641 }
642}
643
644fn write_if_missing(path: impl AsRef<Path>, contents: &str) -> Result<()> {
645 let path = path.as_ref();
646 if path.exists() {
647 return Ok(());
648 }
649 fs::write(path, contents)?;
650 Ok(())
651}
652
653fn render_template(template: &str, vars: &[(&str, String)]) -> String {
654 let mut output = template.to_string();
655 for (key, value) in vars {
656 let token = format!("{{{{{key}}}}}");
657 output = output.replace(&token, value);
658 }
659 output
660}
661
662fn write_user_file(path: impl AsRef<Path>, contents: &str, defaults: &[&str]) -> Result<()> {
663 let path = path.as_ref();
664 if path.exists() {
665 let existing = fs::read_to_string(path)?;
666 let normalized = existing.trim().replace("\r\n", "\n");
667 let is_default = defaults
668 .iter()
669 .any(|candidate| candidate.trim().replace("\r\n", "\n") == normalized);
670 if !is_default {
671 return Ok(());
672 }
673 }
674 fs::write(path, contents)?;
675 Ok(())
676}
677
678fn user_models_stub() -> &'static str {
679 "from .generated_models import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
680}
681
682fn user_admin_stub() -> &'static str {
683 "from .generated_admin import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
684}
685
686fn user_serializers_stub() -> &'static str {
687 "from .generated_serializers import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
688}
689
690fn user_views_stub() -> &'static str {
691 "from .generated_views import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
692}
693
694fn user_urls_stub() -> &'static str {
695 "from .generated_urls import * # noqa: F401,F403\n"
696}
697
698fn user_extensions_stub() -> &'static str {
699 "# User extension hooks live here.\n"
700}
701
702fn default_models_stub() -> &'static str {
703 "from django.db import models\n\n# Create your models here.\n"
704}
705
706fn default_admin_stub() -> &'static str {
707 "from django.contrib import admin\n\n# Register your models here.\n"
708}
709
710fn default_views_stub() -> &'static str {
711 "from django.shortcuts import render\n\n# Create your views here.\n"
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use alembic_core::{
718 FieldSchema, FieldType, Inventory, JsonMap, Object, Schema, TypeName, TypeSchema,
719 };
720 use serde_json::{json, Value};
721 use std::collections::BTreeMap;
722 use tempfile::tempdir;
723 use uuid::Uuid;
724
725 fn attrs_map(pairs: Vec<(&str, Value)>) -> JsonMap {
726 JsonMap::from(
727 pairs
728 .into_iter()
729 .map(|(key, value)| (key.to_string(), value))
730 .collect::<BTreeMap<_, _>>(),
731 )
732 }
733
734 fn key_str(raw: &str) -> alembic_core::Key {
735 let mut map = BTreeMap::new();
736 for segment in raw.split('/') {
737 let (field, value) = segment
738 .split_once('=')
739 .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
740 map.insert(field.to_string(), Value::String(value.to_string()));
741 }
742 alembic_core::Key::from(map)
743 }
744
745 fn obj(uid: u128, type_name: &str, key: &str, attrs: JsonMap) -> Object {
746 Object::new(
747 Uuid::from_u128(uid),
748 TypeName::new(type_name),
749 key_str(key),
750 attrs,
751 )
752 .unwrap()
753 }
754
755 fn test_schema() -> Schema {
756 let mut types = BTreeMap::new();
757 types.insert(
758 "dcim.site".to_string(),
759 TypeSchema {
760 key: BTreeMap::from([(
761 "slug".to_string(),
762 FieldSchema {
763 r#type: FieldType::Slug,
764 required: true,
765 nullable: false,
766 description: None,
767 format: None,
768 pattern: None,
769 },
770 )]),
771 fields: BTreeMap::from([
772 (
773 "name".to_string(),
774 FieldSchema {
775 r#type: FieldType::String,
776 required: true,
777 nullable: false,
778 description: None,
779 format: None,
780 pattern: None,
781 },
782 ),
783 (
784 "slug".to_string(),
785 FieldSchema {
786 r#type: FieldType::Slug,
787 required: true,
788 nullable: false,
789 description: None,
790 format: None,
791 pattern: None,
792 },
793 ),
794 ]),
795 },
796 );
797 types.insert(
798 "dcim.device".to_string(),
799 TypeSchema {
800 key: BTreeMap::from([(
801 "name".to_string(),
802 FieldSchema {
803 r#type: FieldType::Slug,
804 required: true,
805 nullable: false,
806 description: None,
807 format: None,
808 pattern: None,
809 },
810 )]),
811 fields: BTreeMap::from([
812 (
813 "name".to_string(),
814 FieldSchema {
815 r#type: FieldType::String,
816 required: true,
817 nullable: false,
818 description: None,
819 format: None,
820 pattern: None,
821 },
822 ),
823 (
824 "site".to_string(),
825 FieldSchema {
826 r#type: FieldType::Ref {
827 target: "dcim.site".to_string(),
828 },
829 required: true,
830 nullable: false,
831 description: None,
832 format: None,
833 pattern: None,
834 },
835 ),
836 (
837 "role".to_string(),
838 FieldSchema {
839 r#type: FieldType::String,
840 required: true,
841 nullable: false,
842 description: None,
843 format: None,
844 pattern: None,
845 },
846 ),
847 (
848 "device_type".to_string(),
849 FieldSchema {
850 r#type: FieldType::String,
851 required: true,
852 nullable: false,
853 description: None,
854 format: None,
855 pattern: None,
856 },
857 ),
858 ]),
859 },
860 );
861 types.insert(
862 "dcim.interface".to_string(),
863 TypeSchema {
864 key: BTreeMap::from([(
865 "name".to_string(),
866 FieldSchema {
867 r#type: FieldType::Slug,
868 required: true,
869 nullable: false,
870 description: None,
871 format: None,
872 pattern: None,
873 },
874 )]),
875 fields: BTreeMap::from([
876 (
877 "name".to_string(),
878 FieldSchema {
879 r#type: FieldType::String,
880 required: true,
881 nullable: false,
882 description: None,
883 format: None,
884 pattern: None,
885 },
886 ),
887 (
888 "device".to_string(),
889 FieldSchema {
890 r#type: FieldType::Ref {
891 target: "dcim.device".to_string(),
892 },
893 required: true,
894 nullable: false,
895 description: None,
896 format: None,
897 pattern: None,
898 },
899 ),
900 ]),
901 },
902 );
903 Schema { types }
904 }
905
906 fn sample_inventory() -> Inventory {
907 let objects = vec![
908 obj(
909 1,
910 "dcim.device",
911 "name=leaf01",
912 attrs_map(vec![
913 ("name", json!("leaf01")),
914 ("site", json!(Uuid::from_u128(2).to_string())),
915 ("role", json!("leaf")),
916 ("device_type", json!("leaf-switch")),
917 ]),
918 ),
919 obj(
920 2,
921 "dcim.site",
922 "slug=fra1",
923 attrs_map(vec![("name", json!("FRA1")), ("slug", json!("fra1"))]),
924 ),
925 obj(
926 3,
927 "dcim.interface",
928 "name=eth0",
929 attrs_map(vec![
930 ("name", json!("eth0")),
931 ("device", json!(Uuid::from_u128(1).to_string())),
932 ]),
933 ),
934 ];
935 Inventory {
936 schema: test_schema(),
937 objects,
938 }
939 }
940
941 #[test]
942 fn emit_django_app_writes_files_and_stubs() {
943 let dir = tempdir().unwrap();
944 emit_django_app(
945 dir.path(),
946 &sample_inventory(),
947 DjangoEmitOptions::default(),
948 )
949 .unwrap();
950
951 assert!(dir.path().join(GENERATED_MODELS).exists());
952 assert!(dir.path().join(GENERATED_ADMIN).exists());
953 assert!(dir.path().join(GENERATED_SERIALIZERS).exists());
954 assert!(dir.path().join(GENERATED_VIEWS).exists());
955 assert!(dir.path().join(GENERATED_URLS).exists());
956 assert!(dir.path().join(USER_MODELS).exists());
957 assert!(dir.path().join(USER_ADMIN).exists());
958 assert!(dir.path().join(USER_SERIALIZERS).exists());
959 assert!(dir.path().join(USER_VIEWS).exists());
960 assert!(dir.path().join(USER_URLS).exists());
961 assert!(dir.path().join(USER_EXTENSIONS).exists());
962
963 let models = fs::read_to_string(dir.path().join(GENERATED_MODELS)).unwrap();
964 assert!(models.contains("class DcimSite"));
965 assert!(models.contains("site = models.ForeignKey(\"DcimSite\""));
966 assert!(models.contains("device = models.ForeignKey(\"DcimDevice\""));
967 assert!(models.contains("uid = models.UUIDField"));
968 assert!(models.contains("attrs = models.JSONField"));
969 }
970
971 #[test]
972 fn emit_django_app_does_not_overwrite_user_files() {
973 let dir = tempdir().unwrap();
974 let models_path = dir.path().join(USER_MODELS);
975 let admin_path = dir.path().join(USER_ADMIN);
976 fs::write(&models_path, "# user models\n").unwrap();
977 fs::write(&admin_path, "# user admin\n").unwrap();
978
979 emit_django_app(
980 dir.path(),
981 &sample_inventory(),
982 DjangoEmitOptions::default(),
983 )
984 .unwrap();
985
986 assert_eq!(fs::read_to_string(models_path).unwrap(), "# user models\n");
987 assert_eq!(fs::read_to_string(admin_path).unwrap(), "# user admin\n");
988 }
989
990 #[test]
991 fn emit_django_app_overwrites_default_skeleton() {
992 let dir = tempdir().unwrap();
993 let models_path = dir.path().join(USER_MODELS);
994 let admin_path = dir.path().join(USER_ADMIN);
995 let views_path = dir.path().join(USER_VIEWS);
996 fs::write(&models_path, default_models_stub()).unwrap();
997 fs::write(&admin_path, default_admin_stub()).unwrap();
998 fs::write(&views_path, default_views_stub()).unwrap();
999
1000 emit_django_app(
1001 dir.path(),
1002 &sample_inventory(),
1003 DjangoEmitOptions::default(),
1004 )
1005 .unwrap();
1006
1007 let models = fs::read_to_string(models_path).unwrap();
1008 let admin = fs::read_to_string(admin_path).unwrap();
1009 let views = fs::read_to_string(views_path).unwrap();
1010 assert!(models.contains("generated_models"));
1011 assert!(admin.contains("generated_admin"));
1012 assert!(views.contains("generated_views"));
1013 }
1014
1015 #[test]
1016 fn generated_admin_includes_defaults() {
1017 let dir = tempdir().unwrap();
1018 emit_django_app(
1019 dir.path(),
1020 &sample_inventory(),
1021 DjangoEmitOptions::default(),
1022 )
1023 .unwrap();
1024 let admin = fs::read_to_string(dir.path().join(GENERATED_ADMIN)).unwrap();
1025
1026 assert!(admin.contains("class DcimDeviceAdmin"));
1027 assert!(admin.contains(
1028 "list_display = [\"key\", \"uid\", \"name\", \"device_type\", \"role\", \"site\"]"
1029 ));
1030 assert!(admin.contains("search_fields = [\"key\", \"uid\"]"));
1031 assert!(admin.contains("class DcimInterfaceAdmin"));
1032 }
1033
1034 #[test]
1035 fn generated_api_files_include_models() {
1036 let dir = tempdir().unwrap();
1037 emit_django_app(
1038 dir.path(),
1039 &sample_inventory(),
1040 DjangoEmitOptions::default(),
1041 )
1042 .unwrap();
1043 let serializers = fs::read_to_string(dir.path().join(GENERATED_SERIALIZERS)).unwrap();
1044 let views = fs::read_to_string(dir.path().join(GENERATED_VIEWS)).unwrap();
1045 let urls = fs::read_to_string(dir.path().join(GENERATED_URLS)).unwrap();
1046
1047 assert!(serializers.contains("class DcimDeviceSerializer"));
1048 assert!(views.contains("class DcimDeviceViewSet"));
1049 assert!(urls.contains("router.register(\"dcimdevices\""));
1050 assert!(urls.contains("schema_view"));
1051 }
1052
1053 #[test]
1054 fn generated_models_are_deterministic_by_kind() {
1055 let dir = tempdir().unwrap();
1056 emit_django_app(
1057 dir.path(),
1058 &sample_inventory(),
1059 DjangoEmitOptions::default(),
1060 )
1061 .unwrap();
1062
1063 let models = fs::read_to_string(dir.path().join(GENERATED_MODELS)).unwrap();
1064 let device_pos = models.find("class DcimDevice").unwrap();
1065 let interface_pos = models.find("class DcimInterface").unwrap();
1066 let site_pos = models.find("class DcimSite").unwrap();
1067 assert!(device_pos < interface_pos);
1068 assert!(interface_pos < site_pos);
1069 }
1070}