diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index ec4100149f1..5193c0cf72a 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -36,6 +36,7 @@
*** xref:pythonlib/module-config.adoc[]
*** xref:pythonlib/dependencies.adoc[]
*** xref:pythonlib/publishing.adoc[]
+*** xref:pythonlib/web-examples.adoc[]
* xref:comparisons/why-mill.adoc[]
** xref:comparisons/maven.adoc[]
** xref:comparisons/gradle.adoc[]
diff --git a/docs/modules/ROOT/pages/pythonlib/web-examples.adoc b/docs/modules/ROOT/pages/pythonlib/web-examples.adoc
new file mode 100644
index 00000000000..5118e2a3220
--- /dev/null
+++ b/docs/modules/ROOT/pages/pythonlib/web-examples.adoc
@@ -0,0 +1,25 @@
+= Python Web Project Examples
+:page-aliases: Python_Web_Examples.adoc
+
+include::partial$gtag-config.adoc[]
+
+This page provides examples of using Mill as a build tool for Python web applications.
+It includes setting up a basic "Hello, World!" application and developing a fully
+functional https://todomvc.com/[TodoMVC] app with Flask and Django, showcasing best practices
+for project organization, scalability, and maintainability.
+
+== Flask Hello World App
+
+include::partial$example/pythonlib/web/1-hello-flask.adoc[]
+
+== Flask TodoMVC App
+
+include::partial$example/pythonlib/web/2-todo-flask.adoc[]
+
+== Django Hello World App
+
+include::partial$example/pythonlib/web/3-hello-django.adoc[]
+
+== Django TodoMVC App
+
+include::partial$example/pythonlib/web/4-todo-django.adoc[]
\ No newline at end of file
diff --git a/example/package.mill b/example/package.mill
index 098e99734b7..9912e29697c 100644
--- a/example/package.mill
+++ b/example/package.mill
@@ -70,6 +70,7 @@ object `package` extends RootModule with Module {
object dependencies extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "dependencies"))
object publishing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "publishing"))
object module extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "module"))
+ object web extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "web"))
}
object cli extends Module{
diff --git a/example/pythonlib/web/1-hello-flask/build.mill b/example/pythonlib/web/1-hello-flask/build.mill
new file mode 100644
index 00000000000..661c36a9b70
--- /dev/null
+++ b/example/pythonlib/web/1-hello-flask/build.mill
@@ -0,0 +1,39 @@
+// This example uses Mill to manage a Flask app that serves `"Hello, Mill!"`
+// at the root URL (`/`), with Flask installed as a dependency
+// and tests enabled using `unittest`.
+package build
+import mill._, pythonlib._
+
+object foo extends PythonModule {
+
+ def mainScript = Task.Source { millSourcePath / "src/foo.py" }
+
+ def pythonDeps = Seq("flask==3.1.0")
+
+ object test extends PythonTests with TestModule.Unittest
+
+}
+
+// Running these commands will test and run the Flask server with desired outputs.
+
+// The app is ready to serve at `http://localhost:5000`.
+
+/** Usage
+
+> ./mill foo.test
+...
+test_hello_flask (test.TestScript...)
+Test the '/' endpoint. ... ok
+...
+Ran 1 test...
+OK
+...
+
+> ./mill foo.runBackground
+
+> curl http://localhost:5000
+...
Hello, Mill! ...
+
+> ./mill clean foo.runBackground
+
+*/
diff --git a/example/pythonlib/web/1-hello-flask/foo/src/foo.py b/example/pythonlib/web/1-hello-flask/foo/src/foo.py
new file mode 100644
index 00000000000..cc0d2bcc146
--- /dev/null
+++ b/example/pythonlib/web/1-hello-flask/foo/src/foo.py
@@ -0,0 +1,12 @@
+from flask import Flask
+
+app = Flask(__name__)
+
+
+@app.route("/")
+def hello_world():
+ return "Hello, Mill! "
+
+
+if __name__ == "__main__":
+ app.run(debug=True)
diff --git a/example/pythonlib/web/1-hello-flask/foo/test/src/test.py b/example/pythonlib/web/1-hello-flask/foo/test/src/test.py
new file mode 100644
index 00000000000..eb7a184cb21
--- /dev/null
+++ b/example/pythonlib/web/1-hello-flask/foo/test/src/test.py
@@ -0,0 +1,21 @@
+import unittest
+from foo import app # type: ignore
+
+
+class TestScript(unittest.TestCase):
+ def setUp(self):
+ """Set up the test client before each test."""
+ self.app = app.test_client() # Initialize the test client
+ self.app.testing = True # Enable testing mode for better error handling
+
+ def test_hello_flask(self):
+ """Test the '/' endpoint."""
+ response = self.app.get("/") # Simulate a GET request to the root endpoint
+ self.assertEqual(response.status_code, 200) # Check the HTTP status code
+ self.assertIn(
+ b"Hello, Mill!", response.data
+ ) # Check if the response contains the expected text
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/example/pythonlib/web/2-todo-flask/build.mill b/example/pythonlib/web/2-todo-flask/build.mill
new file mode 100644
index 00000000000..ac903686234
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/build.mill
@@ -0,0 +1,64 @@
+// This is a `Flask`-based https://todomvc.com/[TodoMVC] application managed and built using `Mill`.
+// It allows users to `add`, `edit`, `delete`, and `view` tasks stored in a Python Data Structure.
+// Tasks can be filtered as `all`, `active`, or `completed` based on their state.
+// The application demonstrates dynamic rendering using Flask's routing and templating features.
+package build
+import mill._, pythonlib._
+
+object todo extends PythonModule {
+
+ def mainScript = Task.Source { millSourcePath / "src/app.py" }
+
+ def pythonDeps = Seq("flask==3.1.0", "Flask-SQLAlchemy==3.1.1", "Flask-WTF==1.2.2")
+
+ object test extends PythonTests with TestModule.Unittest
+ object itest extends PythonTests with TestModule.Unittest
+
+}
+
+// Apart from running a web server, this example demonstrates:
+
+// - **Serving HTML templates** using **Jinja2** (Flask's default templating engine).
+// - **Managing static files** such as JavaScript, CSS, and images.
+// - **Filtering and managing tasks** in-memory using Python data structures.
+// - **Unit testing** using **unittest** for testing task operations.
+// - **Integration testing** using **unittest** for end-to-end application behavior.
+
+// This example also utilizes **Mill** for managing `dependencies`, `builds`, and `tests`,
+// offering an efficient development workflow.
+
+// The app is ready to serve at `http://localhost:5001`.
+
+/** Usage
+
+> ./mill todo.test
+...
+test_add_todo (test.TestTodoApp...) ... ok
+test_delete_todo (test.TestTodoApp...) ... ok
+test_edit_todo (test.TestTodoApp...) ... ok
+test_filter_todos (test.TestTodoApp...) ... ok
+test_toggle_all (test.TestTodoApp...) ... ok
+test_toggle_todo (test.TestTodoApp...) ... ok
+...Ran 6 tests...
+OK
+...
+
+> ./mill todo.itest
+...
+test_add_and_list_todos (test.TestTodoAppIntegration...) ... ok
+test_delete_todo (test.TestTodoAppIntegration...) ... ok
+test_edit_and_list_todos (test.TestTodoAppIntegration...) ... ok
+test_toggle_all_todos (test.TestTodoAppIntegration...) ... ok
+test_toggle_and_list_todos (test.TestTodoAppIntegration...) ... ok
+...Ran 5 tests...
+OK
+...
+
+> ./mill todo.runBackground
+
+> curl http://localhost:5001
+...What needs to be done...
+
+> ./mill clean todo.runBackground
+
+*/
diff --git a/example/pythonlib/web/2-todo-flask/todo/itest/src/test.py b/example/pythonlib/web/2-todo-flask/todo/itest/src/test.py
new file mode 100644
index 00000000000..c371708f2cb
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/itest/src/test.py
@@ -0,0 +1,68 @@
+import unittest
+from app import app, todos
+
+
+class TestTodoAppIntegration(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Set up the test client for the app
+ cls.client = app.test_client()
+
+ def setUp(self):
+ # Clear the todos list before each test
+ global todos
+ todos.clear()
+
+ def test_add_and_list_todos(self):
+ # Test adding a todo and listing all todos
+ response = self.client.post("/add/all", data="Test Todo")
+ self.assertEqual(response.status_code, 200)
+ # Fetch the todos list and verify the item was added
+ response = self.client.post("/list/all", data="")
+ self.assertIn(b"Test Todo", response.data)
+
+ def test_toggle_and_list_todos(self):
+ # Test adding a todo, toggling it, and listing active/completed todos
+ self.client.post("/add/all", data="Test Todo")
+ response = self.client.post("/toggle/all/0", data="")
+ # Check if the todo is toggled
+ self.assertEqual(response.status_code, 200)
+ # Now, test filtering todos based on active/completed state
+ response = self.client.post("/list/active", data="")
+ self.assertNotIn(b"Test Todo", response.data)
+ response = self.client.post("/list/completed", data="")
+ self.assertIn(b"Test Todo", response.data)
+
+ def test_edit_and_list_todos(self):
+ # Test adding a todo, editing it, and then verifying the updated text
+ self.client.post("/add/all", data="Test Todo")
+ response = self.client.post("/edit/all/0", data="Updated Todo")
+ # Check that the todo was updated
+ response = self.client.post("/list/all", data="")
+ self.assertIn(b"Updated Todo", response.data)
+ self.assertNotIn(b"Test Todo", response.data)
+
+ def test_delete_todo(self):
+ # Test adding and deleting a todo
+ self.client.post("/add/all", data="Test Todo")
+ response = self.client.post("/delete/all/0", data="")
+ # Verify that the todo was deleted
+ response = self.client.post("/list/all", data="")
+ self.assertNotIn(b"Test Todo", response.data)
+
+ def test_toggle_all_todos(self):
+ # Test toggling all todos
+ self.client.post("/add/all", data="Todo 1")
+ self.client.post("/add/all", data="Todo 2")
+ response = self.client.post("/toggle-all/all", data="")
+ response = self.client.post("/list/completed", data="")
+ self.assertIn(b"Todo 1", response.data)
+ self.assertIn(b"Todo 2", response.data)
+ response = self.client.post("/toggle-all/all", data="")
+ response = self.client.post("/list/active", data="")
+ self.assertIn(b"Todo 1", response.data)
+ self.assertIn(b"Todo 2", response.data)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/example/pythonlib/web/2-todo-flask/todo/src/app.py b/example/pythonlib/web/2-todo-flask/todo/src/app.py
new file mode 100644
index 00000000000..5b631a3941d
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/src/app.py
@@ -0,0 +1,96 @@
+from flask import Flask, render_template, request
+from dataclasses import dataclass
+from typing import List
+
+app = Flask(__name__, static_folder="../static", template_folder="../templates")
+
+
+@dataclass
+class Todo:
+ checked: bool
+ text: str
+
+
+todos: List[Todo] = []
+
+
+@app.route("/")
+def index():
+ return render_template("base.html", todos=todos, state="all")
+
+
+def render_body(state: str):
+ filtered_todos = {
+ "all": todos,
+ "active": [todo for todo in todos if not todo.checked],
+ "completed": [todo for todo in todos if todo.checked],
+ }[state]
+ return render_template("index.html", todos=filtered_todos, state=state)
+
+
+def filter_todos(state):
+ """Filter todos based on the state (all, active, completed)."""
+ if state == "all":
+ return todos
+ elif state == "active":
+ return [todo for todo in todos if not todo.checked]
+ elif state == "completed":
+ return [todo for todo in todos if todo.checked]
+
+
+@app.route("/edit//", methods=["POST"])
+def edit_todo(state, index):
+ """Edit the text of a todo."""
+ global todos
+ updated_text = request.data.decode("utf-8")
+ # Update the text attribute of the Todo object
+ todos[index].text = updated_text
+ filtered_todos = filter_todos(state)
+ return render_template("index.html", todos=filtered_todos, state=state)
+
+
+@app.route("/list/", methods=["POST"])
+def list_todos(state):
+ return render_body(state)
+
+
+@app.route("/add/", methods=["POST"])
+def add_todo(state):
+ todos.insert(0, Todo(checked=False, text=request.data.decode("utf-8")))
+ return render_body(state)
+
+
+@app.route("/delete//", methods=["POST"])
+def delete_todo(state, index):
+ if 0 <= index < len(todos):
+ todos.pop(index)
+ return render_body(state)
+
+
+@app.route("/toggle//", methods=["POST"])
+def toggle(state, index):
+ if 0 <= index < len(todos):
+ todos[index].checked = not todos[index].checked
+ return render_body(state)
+
+
+@app.route("/clear-completed/", methods=["POST"])
+def clear_completed(state):
+ global todos
+ todos = [todo for todo in todos if not todo.checked]
+ return render_body(state)
+
+
+@app.route("/toggle-all/", methods=["POST"])
+def toggle_all(state):
+ global todos
+
+ all_checked = all(todo.checked for todo in todos)
+ for todo in todos:
+ todo.checked = not all_checked
+
+ return render_body(state)
+
+
+if __name__ == "__main__":
+ app.run(debug=True, port=5001)
diff --git a/example/pythonlib/web/2-todo-flask/todo/static/main.js b/example/pythonlib/web/2-todo-flask/todo/static/main.js
new file mode 100644
index 00000000000..dadd364230c
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/static/main.js
@@ -0,0 +1,112 @@
+var state = "all";
+
+var todoApp = document.getElementsByClassName("todoapp")[0];
+
+function postFetchUpdate(url) {
+ fetch(url, {
+ method: "POST",
+ })
+ .then(function (response) { return response.text(); })
+ .then(function (text) {
+ todoApp.innerHTML = text;
+ initListeners();
+ });
+}
+
+function bindEvent(cls, url, endState) {
+ document.getElementsByClassName(cls)[0].addEventListener(
+ "mousedown",
+ function (evt) {
+ postFetchUpdate(url);
+ if (endState) state = endState;
+ }
+ );
+}
+
+function bindIndexedEvent(cls, func) {
+ Array.from(document.getElementsByClassName(cls)).forEach(function (elem) {
+ elem.addEventListener(
+ "mousedown",
+ function (evt) {
+ postFetchUpdate(func(elem.getAttribute("data-todo-index")));
+ }
+ );
+ });
+}
+
+function initListeners() {
+ // Bind events for deleting and toggling todos
+ bindIndexedEvent(
+ "destroy",
+ function (index) { return "/delete/" + state + "/" + index; }
+ );
+ bindIndexedEvent(
+ "toggle",
+ function (index) { return "/toggle/" + state + "/" + index; }
+ );
+
+ // Bind events for global actions
+ bindEvent("toggle-all", "/toggle-all/" + state);
+ bindEvent("todo-all", "/list/all", "all");
+ bindEvent("todo-active", "/list/active", "active");
+ bindEvent("todo-completed", "/list/completed", "completed");
+ bindEvent("clear-completed", "/clear-completed/" + state);
+
+ // Event for adding new todos
+ var newTodoInput = document.getElementsByClassName("new-todo")[0];
+ newTodoInput.addEventListener(
+ "keydown",
+ function (evt) {
+ if (evt.keyCode === 13) { // Enter key
+ fetch("/add/" + state, {
+ method: "POST",
+ body: newTodoInput.value
+ })
+ .then(function (response) { return response.text(); })
+ .then(function (text) {
+ newTodoInput.value = "";
+ todoApp.innerHTML = text;
+ initListeners();
+ });
+ }
+ }
+ );
+
+ // Add double-click event to labels for editing todos
+ Array.from(document.querySelectorAll(".todo-list label")).forEach(function (label) {
+ label.addEventListener("dblclick", function () {
+ var li = label.closest("li");
+ li.classList.add("editing");
+
+ var editInput = li.querySelector(".edit");
+ editInput.value = label.textContent;
+ editInput.focus();
+
+ // Save on blur or Enter key
+ function saveEdit() {
+ var index = editInput.closest("li").querySelector(".toggle").getAttribute("data-todo-index");
+ var updatedText = editInput.value;
+
+ fetch("/edit/" + state + "/" + index, {
+ method: "POST",
+ body: updatedText
+ })
+ .then(function (response) { return response.text(); })
+ .then(function (text) {
+ todoApp.innerHTML = text;
+ initListeners();
+ });
+ }
+
+ editInput.addEventListener("blur", saveEdit);
+ editInput.addEventListener("keydown", function (evt) {
+ if (evt.keyCode === 13) { // Enter key
+ saveEdit();
+ }
+ });
+ });
+ });
+}
+
+// Initialize event listeners when the page loads
+initListeners();
\ No newline at end of file
diff --git a/example/pythonlib/web/2-todo-flask/todo/static/styles.css b/example/pythonlib/web/2-todo-flask/todo/static/styles.css
new file mode 100644
index 00000000000..c81f4951668
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/static/styles.css
@@ -0,0 +1,369 @@
+@charset "utf-8";
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+
+.toggle-all {
+ position: absolute;
+ top: -53px;
+ left: -10px;
+ width: 60px;
+ height: 34px;
+ text-align: center;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none;
+ /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle+label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked+label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ word-break: break-all;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus+label,
+.toggle-all:focus+label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
\ No newline at end of file
diff --git a/example/pythonlib/web/2-todo-flask/todo/templates/base.html b/example/pythonlib/web/2-todo-flask/todo/templates/base.html
new file mode 100644
index 00000000000..e8e5ed9c54b
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/templates/base.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Flask • TodoMVC
+
+
+
+
+
+ {% include 'index.html' %}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/example/pythonlib/web/2-todo-flask/todo/templates/index.html b/example/pythonlib/web/2-todo-flask/todo/templates/index.html
new file mode 100644
index 00000000000..337b8789507
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/templates/index.html
@@ -0,0 +1,31 @@
+
+
+
\ No newline at end of file
diff --git a/example/pythonlib/web/2-todo-flask/todo/test/src/test.py b/example/pythonlib/web/2-todo-flask/todo/test/src/test.py
new file mode 100644
index 00000000000..5a8e25618b9
--- /dev/null
+++ b/example/pythonlib/web/2-todo-flask/todo/test/src/test.py
@@ -0,0 +1,64 @@
+import unittest
+from app import app, todos, Todo
+
+
+class TestTodoApp(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Set up the test client for the app
+ cls.client = app.test_client()
+
+ def setUp(self):
+ # Clear the todos list before each test
+ global todos
+ todos.clear()
+
+ def test_add_todo(self):
+ # Add a todo item
+ response = self.client.post("/add/all", data="Test Todo")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(todos), 1)
+ self.assertEqual(todos[0].text, "Test Todo")
+
+ def test_toggle_todo(self):
+ # Add a todo item and toggle it
+ todos.append(Todo(checked=False, text="Test Todo"))
+ response = self.client.post("/toggle/all/0", data="")
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(todos[0].checked)
+
+ def test_edit_todo(self):
+ # Add a todo item and edit it
+ todos.append(Todo(checked=False, text="Test Todo"))
+ response = self.client.post("/edit/all/0", data="Updated Todo")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(todos[0].text, "Updated Todo")
+
+ def test_delete_todo(self):
+ # Add a todo item and delete it
+ todos.append(Todo(checked=False, text="Test Todo"))
+ response = self.client.post("/delete/all/0", data="")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(todos), 0)
+
+ def test_toggle_all(self):
+ # Add todos and toggle all
+ todos.append(Todo(checked=False, text="Todo 1"))
+ todos.append(Todo(checked=False, text="Todo 2"))
+ response = self.client.post("/toggle-all/all", data="")
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(all(todo.checked for todo in todos))
+
+ def test_filter_todos(self):
+ # Add todos and test the filter functionality
+ todos.append(Todo(checked=False, text="Active Todo"))
+ todos.append(Todo(checked=True, text="Completed Todo"))
+ response = self.client.post("/list/active", data="")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b"Active Todo", response.data)
+ self.assertNotIn(b"Completed Todo", response.data)
+ self.assertEqual(len(todos), 2) # Should still have 2 todos
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/example/pythonlib/web/3-hello-django/build.mill b/example/pythonlib/web/3-hello-django/build.mill
new file mode 100644
index 00000000000..cd85cfc7afa
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/build.mill
@@ -0,0 +1,36 @@
+// This example demonstrates a minimal `Django` application managed with Mill.
+// It features a simple view that returns `Hello, Mill! ` and
+// includes `Django's` core functionality for `testing` and `running` a development server.
+package build
+import mill._, pythonlib._
+
+object foo extends PythonModule {
+
+ def mainScript = Task.Source { millSourcePath / "src/manage.py" }
+
+ def pythonDeps = Seq("django==5.1.4")
+
+}
+// Using `Mill`, we can easily manage dependencies, run `Django tests`, and `launch the server`.
+// With just a few commands, the app is ready to serve at `http://localhost:5002`.
+
+/** Usage
+
+> ./mill foo.run test main -v 2 # using inbuilt `django test`, `main` is the app name, `-v 2` is verbosity level 2
+...
+System check identified no issues (0 silenced).
+test_index_view (main.tests.TestScript...)
+Test that the index view returns a 200 status code ... ok
+...
+Ran 1 test...
+OK
+...
+
+> ./mill foo.runBackground runserver 5002
+
+> curl http://localhost:5002
+...Hello, Mill! ...
+
+> ./mill clean foo.runBackground
+
+*/
diff --git a/example/pythonlib/web/3-hello-django/foo/src/app/__init__.py b/example/pythonlib/web/3-hello-django/foo/src/app/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/example/pythonlib/web/3-hello-django/foo/src/app/asgi.py b/example/pythonlib/web/3-hello-django/foo/src/app/asgi.py
new file mode 100644
index 00000000000..df7e978063c
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/app/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for app project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
+
+application = get_asgi_application()
diff --git a/example/pythonlib/web/3-hello-django/foo/src/app/settings.py b/example/pythonlib/web/3-hello-django/foo/src/app/settings.py
new file mode 100644
index 00000000000..3c40a0f9649
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/app/settings.py
@@ -0,0 +1,124 @@
+"""
+Django settings for app project.
+
+Generated by 'django-admin startproject' using Django 5.1.4.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/5.1/ref/settings/
+"""
+
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'django-insecure-7@1hg!(c00%z)t82=^_mu02sxa$nlex_xc!7j++18z5w4dc(iu'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'main',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'app.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'app.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/5.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/5.1/howto/static-files/
+
+STATIC_URL = 'static/'
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/example/pythonlib/web/3-hello-django/foo/src/app/urls.py b/example/pythonlib/web/3-hello-django/foo/src/app/urls.py
new file mode 100644
index 00000000000..879e54d05df
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/app/urls.py
@@ -0,0 +1,24 @@
+"""
+URL configuration for app project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/5.1/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path
+from main import views
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path('', views.index, name="homepage")
+]
diff --git a/example/pythonlib/web/3-hello-django/foo/src/app/wsgi.py b/example/pythonlib/web/3-hello-django/foo/src/app/wsgi.py
new file mode 100644
index 00000000000..829fcc707bb
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/app/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for app project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
+
+application = get_wsgi_application()
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/__init__.py b/example/pythonlib/web/3-hello-django/foo/src/main/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/admin.py b/example/pythonlib/web/3-hello-django/foo/src/main/admin.py
new file mode 100644
index 00000000000..8c38f3f3dad
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/main/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/apps.py b/example/pythonlib/web/3-hello-django/foo/src/main/apps.py
new file mode 100644
index 00000000000..167f04426e4
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/main/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class MainConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'main'
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/migrations/__init__.py b/example/pythonlib/web/3-hello-django/foo/src/main/migrations/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/models.py b/example/pythonlib/web/3-hello-django/foo/src/main/models.py
new file mode 100644
index 00000000000..71a83623907
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/main/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/tests.py b/example/pythonlib/web/3-hello-django/foo/src/main/tests.py
new file mode 100644
index 00000000000..aa6ec79bd6a
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/main/tests.py
@@ -0,0 +1,12 @@
+from django.test import TestCase
+from django.urls import reverse
+
+class TestScript(TestCase):
+ def test_index_view(self):
+ """
+ Test that the index view returns a 200 status code
+ and the expected HTML content.
+ """
+ response = self.client.get(reverse('homepage'))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Hello, Mill! ')
diff --git a/example/pythonlib/web/3-hello-django/foo/src/main/views.py b/example/pythonlib/web/3-hello-django/foo/src/main/views.py
new file mode 100644
index 00000000000..05b8dc7a8f6
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/main/views.py
@@ -0,0 +1,5 @@
+from django.shortcuts import render
+from django.http import HttpResponse
+
+def index(request):
+ return HttpResponse('Hello, Mill! ')
\ No newline at end of file
diff --git a/example/pythonlib/web/3-hello-django/foo/src/manage.py b/example/pythonlib/web/3-hello-django/foo/src/manage.py
new file mode 100755
index 00000000000..1a64b14a0e1
--- /dev/null
+++ b/example/pythonlib/web/3-hello-django/foo/src/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/example/pythonlib/web/4-todo-django/build.mill b/example/pythonlib/web/4-todo-django/build.mill
new file mode 100644
index 00000000000..b992704855f
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/build.mill
@@ -0,0 +1,61 @@
+// This `Django` https://todomvc.com/[TodoMVC] example is a task management application built using `Mill` Build Tool.
+// It features dynamic `HTML` rendering with `Django's` template engine, `CRUD` operations with
+// Django `ORM`, and efficient form handling for managing tasks.
+package build
+import mill._, pythonlib._
+
+object todo extends PythonModule {
+
+ def mainScript = Task.Source { millSourcePath / "src/manage.py" }
+
+ def pythonDeps = Seq("django==5.1.4")
+
+}
+
+// Apart from running a `web server`, this example demonstrates:
+
+// - **Serving HTML templates** using **Django's template engine**.
+// - **Managing static files** such as JavaScript, CSS, and images.
+// - **Querying a SQL database** using **Django ORM** with an **SQLite** backend.
+// - **Filtering and managing tasks** using SQLite database.
+// - **Unit + Integration testing** using Django's inbuilt testing framework.
+// - **URL routing** and **views** for CRUD operations.
+
+// This Mill Build File simplifies `dependency management`, `database migrations`, and `testing workflows`.
+
+// Running these commands will `test` and `run` the `Django Todo App`.
+// The app is ready to serve at `http://localhost:5003`.
+
+/** Usage
+
+> ./mill todo.run makemigrations
+...Migrations for 'main'...
+...+ Create model Todo...
+
+> ./mill todo.run migrate
+...Operations to perform...
+...Apply all migrations: admin, auth, contenttypes, main, sessions...
+...Running migrations...
+...OK...
+
+> ./mill todo.run test main -v 2
+...Found 8 test(s)...
+test_add_todo_view (main.tests.TodoAppTests...) ... ok
+test_clear_completed_view (main.tests.TodoAppTests...) ... ok
+test_delete_todo_view (main.tests.TodoAppTests...) ... ok
+test_edit_todo_view (main.tests.TodoAppTests...) ... ok
+test_index_view (main.tests.TodoAppTests...) ... ok
+test_list_todos_view (main.tests.TodoAppTests...) ... ok
+test_toggle_all_view (main.tests.TodoAppTests...) ... ok
+test_toggle_todo_view (main.tests.TodoAppTests...) ... ok
+...Ran 8 tests...
+...OK...
+
+> ./mill todo.runBackground runserver 5003
+
+> curl http://localhost:5003
+...What needs to be done...
+
+> ./mill clean todo.runBackground
+
+*/
diff --git a/example/pythonlib/web/4-todo-django/todo/src/app/__init__.py b/example/pythonlib/web/4-todo-django/todo/src/app/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/example/pythonlib/web/4-todo-django/todo/src/app/asgi.py b/example/pythonlib/web/4-todo-django/todo/src/app/asgi.py
new file mode 100644
index 00000000000..0c9af490b03
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/app/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for app project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
+
+application = get_asgi_application()
diff --git a/example/pythonlib/web/4-todo-django/todo/src/app/settings.py b/example/pythonlib/web/4-todo-django/todo/src/app/settings.py
new file mode 100644
index 00000000000..02216e12238
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/app/settings.py
@@ -0,0 +1,124 @@
+"""
+Django settings for app project.
+
+Generated by 'django-admin startproject' using Django 5.1.4.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/5.1/ref/settings/
+"""
+
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = "django-insecure-on9yu8ulr@rvkw&hv(hfi35j!z#ij0ljbzmz#469*@5$25e@(p"
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "main",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+]
+
+ROOT_URLCONF = "app.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "app.wsgi.application"
+
+
+# Database
+# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/5.1/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/5.1/howto/static-files/
+
+STATIC_URL = "static/"
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/example/pythonlib/web/4-todo-django/todo/src/app/urls.py b/example/pythonlib/web/4-todo-django/todo/src/app/urls.py
new file mode 100644
index 00000000000..463b2df9c25
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/app/urls.py
@@ -0,0 +1,24 @@
+"""
+URL configuration for app project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/5.1/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+
+from django.contrib import admin
+from django.urls import path, include
+
+urlpatterns = [
+ path("admin/", admin.site.urls),
+ path("", include("main.urls")),
+]
diff --git a/example/pythonlib/web/4-todo-django/todo/src/app/wsgi.py b/example/pythonlib/web/4-todo-django/todo/src/app/wsgi.py
new file mode 100644
index 00000000000..3cba99e8cde
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/app/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for app project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
+
+application = get_wsgi_application()
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/__init__.py b/example/pythonlib/web/4-todo-django/todo/src/main/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/admin.py b/example/pythonlib/web/4-todo-django/todo/src/main/admin.py
new file mode 100644
index 00000000000..8c38f3f3dad
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/apps.py b/example/pythonlib/web/4-todo-django/todo/src/main/apps.py
new file mode 100644
index 00000000000..34f1451f2e2
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class MainConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "main"
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/migrations/__init__.py b/example/pythonlib/web/4-todo-django/todo/src/main/migrations/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/models.py b/example/pythonlib/web/4-todo-django/todo/src/main/models.py
new file mode 100644
index 00000000000..16e7fc33dbc
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/models.py
@@ -0,0 +1,5 @@
+from django.db import models
+
+class Todo(models.Model):
+ text = models.CharField(max_length=255)
+ checked = models.BooleanField(default=False)
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/static/main.js b/example/pythonlib/web/4-todo-django/todo/src/main/static/main.js
new file mode 100644
index 00000000000..0b8ce0100c4
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/static/main.js
@@ -0,0 +1,156 @@
+var state = "all";
+
+var todoApp = document.getElementsByClassName("todoapp")[0];
+
+function getCookie(name) {
+ let cookieValue = null;
+ if (document.cookie && document.cookie !== '') {
+ const cookies = document.cookie.split(';');
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i].trim();
+ // Does this cookie string begin with the name we want?
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+ break;
+ }
+ }
+ }
+ return cookieValue;
+}
+
+const csrftoken = getCookie('csrftoken');
+
+function postFetchUpdate(url) {
+ fetch(url, {
+ method: "POST",
+ headers: {
+ 'X-CSRFToken': csrftoken,
+ 'Content-Type': 'text/plain'
+ }
+ })
+ .then(function (response) { return response.text(); })
+ .then(function (text) {
+ todoApp.innerHTML = text;
+ initListeners();
+ })
+ .catch(function (error) {
+ console.error('Error:', error);
+ });
+}
+
+function bindEvent(cls, url, endState) {
+ var element = document.getElementsByClassName(cls)[0];
+ if (element) {
+ element.addEventListener(
+ "mousedown",
+ function (evt) {
+ postFetchUpdate(url + '/');
+ if (endState) state = endState;
+ }
+ );
+ }
+}
+
+function bindIndexedEvent(cls, func) {
+ Array.from(document.getElementsByClassName(cls)).forEach(function (elem) {
+ elem.addEventListener(
+ "mousedown",
+ function (evt) {
+ postFetchUpdate(func(elem.getAttribute("data-todo-index")) + '/');
+ }
+ );
+ });
+}
+
+function initListeners() {
+ // Bind events for deleting and toggling todos
+ bindIndexedEvent(
+ "destroy",
+ function (index) { return "/delete/" + state + "/" + index; }
+ );
+ bindIndexedEvent(
+ "toggle",
+ function (index) { return "/toggle/" + state + "/" + index; }
+ );
+
+ // Bind events for global actions
+ bindEvent("toggle-all", "/toggle-all/" + state);
+ bindEvent("todo-all", "/list/all", "all");
+ bindEvent("todo-active", "/list/active", "active");
+ bindEvent("todo-completed", "/list/completed", "completed");
+ bindEvent("clear-completed", "/clear-completed/" + state);
+
+ // Event for adding new todos
+ var newTodoInput = document.getElementsByClassName("new-todo")[0];
+ if (newTodoInput) {
+ newTodoInput.addEventListener(
+ "keydown",
+ function (evt) {
+ if (evt.keyCode === 13) { // Enter key
+ fetch("/add/" + state + '/', {
+ method: "POST",
+ body: newTodoInput.value,
+ headers: {
+ 'X-CSRFToken': csrftoken,
+ 'Content-Type': 'text/plain'
+ }
+ })
+ .then(function (response) { return response.text(); })
+ .then(function (text) {
+ newTodoInput.value = "";
+ todoApp.innerHTML = text;
+ initListeners();
+ })
+ .catch(function (error) {
+ console.error('Error:', error);
+ });
+ }
+ }
+ );
+ }
+
+ // Add double-click event to labels for editing todos
+ Array.from(document.querySelectorAll(".todo-list label")).forEach(function (label) {
+ label.addEventListener("dblclick", function () {
+ var li = label.closest("li");
+ li.classList.add("editing");
+
+ var editInput = li.querySelector(".edit");
+ editInput.value = label.textContent;
+ editInput.focus();
+
+ // Save on blur or Enter key
+ function saveEdit() {
+ var index = editInput.closest("li").querySelector(".toggle").getAttribute("data-todo-index");
+ var updatedText = editInput.value;
+
+ fetch("/edit/" + state + "/" + index + '/', {
+ method: "POST",
+ body: updatedText,
+ headers: {
+ 'X-CSRFToken': csrftoken,
+ 'Content-Type': 'text/plain'
+ }
+ })
+ .then(function (response) { return response.text(); })
+ .then(function (text) {
+ todoApp.innerHTML = text;
+ initListeners();
+ })
+ .catch(function (error) {
+ console.error('Error:', error);
+ });
+ }
+
+ editInput.addEventListener("blur", saveEdit);
+ editInput.addEventListener("keydown", function (evt) {
+ if (evt.keyCode === 13) { // Enter key
+ saveEdit();
+ }
+ });
+ });
+ });
+}
+
+// Initialize event listeners when the page loads
+initListeners();
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/static/styles.css b/example/pythonlib/web/4-todo-django/todo/src/main/static/styles.css
new file mode 100644
index 00000000000..c81f4951668
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/static/styles.css
@@ -0,0 +1,369 @@
+@charset "utf-8";
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+
+.toggle-all {
+ position: absolute;
+ top: -53px;
+ left: -10px;
+ width: 60px;
+ height: 34px;
+ text-align: center;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none;
+ /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle+label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked+label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ word-break: break-all;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus+label,
+.toggle-all:focus+label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/templates/base.html b/example/pythonlib/web/4-todo-django/todo/src/main/templates/base.html
new file mode 100644
index 00000000000..91f44493679
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/templates/base.html
@@ -0,0 +1,22 @@
+
+
+
+ {% load static %}
+
+
+ Django • TodoMVC
+
+
+
+
+ {% csrf_token %}
+ {% include 'index.html' %}
+
+
+
+
+
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/templates/index.html b/example/pythonlib/web/4-todo-django/todo/src/main/templates/index.html
new file mode 100644
index 00000000000..62e19e5bd34
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/templates/index.html
@@ -0,0 +1,30 @@
+
+
+
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/tests.py b/example/pythonlib/web/4-todo-django/todo/src/main/tests.py
new file mode 100644
index 00000000000..518391df555
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/tests.py
@@ -0,0 +1,73 @@
+from django.test import TestCase, Client
+from django.urls import reverse
+from .models import Todo
+
+class TodoAppTests(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.todo1 = Todo.objects.create(text="Get started with Django", checked=False)
+ self.todo2 = Todo.objects.create(text="Profit!", checked=True)
+
+ def test_index_view(self):
+ response = self.client.get(reverse('index'))
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'index.html')
+ self.assertContains(response, self.todo1.text)
+ self.assertContains(response, self.todo2.text)
+
+ def test_list_todos_view(self):
+ response = self.client.post(reverse('list_todos', args=['all']))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.todo1.text)
+ self.assertContains(response, self.todo2.text)
+
+ response = self.client.post(reverse('list_todos', args=['active']))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.todo1.text)
+ self.assertNotContains(response, self.todo2.text)
+
+ response = self.client.post(reverse('list_todos', args=['completed']))
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, self.todo1.text)
+ self.assertContains(response, self.todo2.text)
+
+ def test_add_todo_view(self):
+ response = self.client.post(reverse('add', args=['all']), data="New Todo", content_type='text/plain')
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "New Todo")
+ self.assertEqual(Todo.objects.count(), 3)
+ todos = Todo.objects.all()
+ self.assertEqual(todos.last().text, "New Todo")
+
+ def test_delete_todo_view(self):
+ response = self.client.post(reverse('delete', args=['all', self.todo1.id-1]))
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, self.todo1.text)
+ self.assertEqual(Todo.objects.count(), 1)
+
+ def test_toggle_todo_view(self):
+ response = self.client.post(reverse('toggle', args=['all', self.todo1.id-1]))
+
+ self.assertEqual(response.status_code, 200)
+ self.todo1.refresh_from_db()
+ self.assertTrue(self.todo1.checked)
+
+ def test_clear_completed_view(self):
+ response = self.client.post(reverse('clear_completed', args=['all']))
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(Todo.objects.filter(checked=True).count(), 0)
+
+ def test_toggle_all_view(self):
+ response = self.client.post(reverse('toggle_all', args=['all']))
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(Todo.objects.filter(checked=True).count(), 2)
+
+ response = self.client.post(reverse('toggle_all', args=['all']))
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(Todo.objects.filter(checked=True).count(), 0)
+
+ def test_edit_todo_view(self):
+ response = self.client.post(reverse('edit', args=['all', self.todo1.id-1]), data="Updated Todo", content_type='text/plain')
+ self.assertEqual(response.status_code, 200)
+ self.todo1.refresh_from_db()
+ self.assertEqual(self.todo1.text, "Updated Todo")
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/urls.py b/example/pythonlib/web/4-todo-django/todo/src/main/urls.py
new file mode 100644
index 00000000000..b3dd51f6590
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/urls.py
@@ -0,0 +1,13 @@
+from django.urls import path
+from . import views
+
+urlpatterns = [
+ path('', views.index, name='index'),
+ path('list//', views.list_todos, name='list_todos'),
+ path('add//', views.add, name='add'),
+ path('delete///', views.delete, name='delete'),
+ path('toggle///', views.toggle, name='toggle'),
+ path('clear-completed//', views.clear_completed, name='clear_completed'),
+ path('toggle-all//', views.toggle_all, name='toggle_all'),
+ path('edit///', views.edit, name='edit'),
+]
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/main/views.py b/example/pythonlib/web/4-todo-django/todo/src/main/views.py
new file mode 100644
index 00000000000..652489c7819
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/main/views.py
@@ -0,0 +1,69 @@
+from django.shortcuts import render
+from .models import Todo
+
+def index(request):
+ todos = Todo.objects.all()
+ context = get_todo_context(todos, 'all')
+ return render(request, 'base.html', context)
+
+def list_todos(request, state):
+ todos = get_filtered_todos(state)
+ context = get_todo_context(todos, state)
+ return render(request, 'index.html', context)
+
+def add(request, state):
+ if request.method == 'POST':
+ Todo.objects.create(text=request.body.decode('utf-8'), checked=False)
+ return list_todos(request, state)
+
+def delete(request, state, index):
+ todos = get_filtered_todos(state)
+ todo = todos[int(index)]
+ todo.delete()
+ return list_todos(request, state)
+
+def toggle(request, state, index):
+ todos = get_filtered_todos(state)
+ todo = todos[int(index)]
+ todo.checked = not todo.checked
+ todo.save()
+ return list_todos(request, state)
+
+def clear_completed(request, state):
+ Todo.objects.filter(checked=True).delete()
+ return list_todos(request, state)
+
+def toggle_all(request, state):
+ todos = Todo.objects.all()
+ next_state = not all(todo.checked for todo in todos)
+ todos.update(checked=next_state)
+ return list_todos(request, state)
+
+def edit(request, state, index):
+ if request.method == 'POST':
+ todos = get_filtered_todos(state)
+ todo = todos[int(index)]
+ todo.text = request.body.decode('utf-8')
+ todo.save()
+ return list_todos(request, state)
+
+def get_filtered_todos(state):
+ if state == 'active':
+ return Todo.objects.filter(checked=False)
+ elif state == 'completed':
+ return Todo.objects.filter(checked=True)
+ return Todo.objects.all()
+
+def get_todo_context(todos, state):
+ total_count = todos.count()
+ active_count = todos.filter(checked=False).count()
+ completed_count = todos.filter(checked=True).count()
+ has_completed = completed_count > 0
+ return {
+ 'todos': todos,
+ 'state': state,
+ 'total_count': total_count,
+ 'active_count': active_count,
+ 'completed_count': completed_count,
+ 'has_completed': has_completed,
+ }
\ No newline at end of file
diff --git a/example/pythonlib/web/4-todo-django/todo/src/manage.py b/example/pythonlib/web/4-todo-django/todo/src/manage.py
new file mode 100755
index 00000000000..1a64b14a0e1
--- /dev/null
+++ b/example/pythonlib/web/4-todo-django/todo/src/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()