diff --git a/.gitignore b/.gitignore
index ad0553d4399d9b09c9050f1223d7836a6c7cec46..e32685c0e24f3a2218ddc2e073c732e54b188f46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,11 +16,11 @@ __pycache__
 
 # Package managers
 /venv*
+/.venv*
 /node_modules/
 *webpack-stats.json
 
 .python-version
-*secrets.json
 !package.json
 !package-lock.json
 !package.vue.json
@@ -51,3 +51,10 @@ start_flower.sh
 whoosh_index
 
 *.pid
+
+# Sensitive files
+*/secrets.json
+/scipost_django/SciPost_v1/settings/local_*.py
+
+# Editor configs
+*.code-workspace
\ No newline at end of file
diff --git a/cronjobs/cronjob_production_eachhour.sh b/cronjobs/cronjob_production_eachhour.sh
index 4189c42039067ed6b6d37cf505aefbc1e3f30183..4cae191cb52431c1c9210d882b55b84e4a69d044 100755
--- a/cronjobs/cronjob_production_eachhour.sh
+++ b/cronjobs/cronjob_production_eachhour.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 
-# Per minute cronjobs for production area
+# Per hour cronjobs for production area
 
 cd /home/scipost/SciPost/scipost_django
 source ../venv-3.8.5/bin/activate
@@ -8,6 +8,7 @@ source ../venv-3.8.5/bin/activate
 # Do tasks
 python manage.py check_celery --settings=SciPost_v1.settings.production_do1
 python manage.py update_coi_via_arxiv --settings=SciPost_v1.settings.production_do1
+python manage.py advance_git_repos --settings=SciPost_v1.settings.production_do1
 
 # Do a update_index of the last hour
 python manage.py update_index -r -v 0 -a 1 --settings=SciPost_v1.settings.production_do1
diff --git a/requirements.txt b/requirements.txt
index f66a1294c612a0d2b04587ac93c586a2d5b6a496..351d26ebd052cd4bfc4323ed13aaaeb156974c0a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,7 +3,7 @@ argon2-cffi==20.1.0		# 2021-07-18 Password hashing algorithm
 Babel==2.9.1			# 2022-01-23
 Django==3.2.18			# 2023-05-06
 feedparser~=6.0.8     # Check: not updated since 2016. [JdW, 2021-09-25] Upgrade to v6; v5 fails.
-psycopg2==2.8.6	  		# 2020-09-19 PostgreSQL engine
+psycopg2==2.9.5	  		# 2020-09-19 PostgreSQL engine -- 2023-05-10 update for python 3.11
 pytz==2021.3			# 2022-11-18 Timezone package
 # djangorestframework==3.9.3	# DEPREC, see next entry -- 2019-12-05  IMPORTANT: update templates/rest_framework/base.html if corresponding file rest_framework/templates/rest_framework/base.html has changed
 git+https://github.com/SciPost/django-rest-framework.git@bootstrap-v5
@@ -80,9 +80,15 @@ celery==5.2.7 		            # 2022-11-18
 django-celery-results==2.4.0 	    # 2022-11-18
 django-celery-beat==2.4.0	    # 2022-11-18
 flower==1.2.0 			    # 2022-11-18
-
+mailchimp3==3.0.18         # 2023-05-09
 
 # Security-related packages
 django-referrer-policy==1.0	# 2020-09-19 no new updates for 3 years
 django-csp==3.7			# 2020-09-19
 django-feature-policy==3.4.0	# 2020-09-19
+
+# Version Control
+python-gitlab==3.14.0		# 2023-05-15
+
+# Preprint server packages
+arxiv==1.4.7            # 2023-05-19
\ No newline at end of file
diff --git a/scipost_django/SciPost_v1/settings/base.py b/scipost_django/SciPost_v1/settings/base.py
index 9d3501b308216bc335cf58bffae9af5cd022f918..64bce3e662eb780d2bbb62384e5f44f344deb24f 100644
--- a/scipost_django/SciPost_v1/settings/base.py
+++ b/scipost_django/SciPost_v1/settings/base.py
@@ -570,3 +570,8 @@ DISCOURSE_SSO_SECRET = get_secret("DISCOURSE_SSO_SECRET")
 CORS_ALLOWED_ORIGINS = [
     "https://git.scipost.org",
 ]
+
+# GitLab API
+GITLAB_ROOT = "SciPost"
+GITLAB_URL = "git.scipost.org"
+GITLAB_KEY = get_secret("GITLAB_KEY")
diff --git a/scipost_django/SciPost_v1/settings/production_do1.py b/scipost_django/SciPost_v1/settings/production_do1.py
index fc9f610f80ae5791813ba11719cd65b809fa5013..14a7feea3d912faf4d534efc6c2f9715388c4001 100644
--- a/scipost_django/SciPost_v1/settings/production_do1.py
+++ b/scipost_django/SciPost_v1/settings/production_do1.py
@@ -88,3 +88,8 @@ CSP_REPORT_ONLY = False
 CORS_ALLOWED_ORIGINS = [
     "https://git.scipost.org",
 ]
+
+# GitLab API
+GITLAB_ROOT = "SciPost"
+GITLAB_URL = "git.scipost.org"
+GITLAB_KEY = get_secret("GITLAB_KEY")
diff --git a/scipost_django/production/admin.py b/scipost_django/production/admin.py
index ac10d9871ead9b7ff01ec0ab3e7a5de867dcbfa6..645fc77ac952b5335078de76f3639077e25a2bcb 100644
--- a/scipost_django/production/admin.py
+++ b/scipost_django/production/admin.py
@@ -12,6 +12,7 @@ from .models import (
     ProductionUser,
     Proofs,
     ProductionEventAttachment,
+    ProofsRepository,
 )
 
 
@@ -95,3 +96,17 @@ admin.site.register(Proofs, ProductionProofsAdmin)
 
 
 admin.site.register(ProductionEventAttachment)
+
+
+class ProofsRepositoryAdmin(GuardedModelAdmin):
+    search_fields = [
+        "stream__submission__author_list",
+        "stream__submission__title",
+        "stream__submission__preprint__identifier_w_vn_nr",
+    ]
+    list_filter = ["status"]
+    list_display = ["stream", "status", "git_path"]
+    readonly_fields = ["template_path", "git_path"]
+
+
+admin.site.register(ProofsRepository, ProofsRepositoryAdmin)
diff --git a/scipost_django/production/management/__init__.py b/scipost_django/production/management/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/scipost_django/production/management/commands/__init__.py b/scipost_django/production/management/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/scipost_django/production/management/commands/advance_git_repos.py b/scipost_django/production/management/commands/advance_git_repos.py
new file mode 100644
index 0000000000000000000000000000000000000000..957d417be9d8f5513ec20013761183f4169232fc
--- /dev/null
+++ b/scipost_django/production/management/commands/advance_git_repos.py
@@ -0,0 +1,533 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+from datetime import datetime
+from functools import reduce
+from itertools import cycle
+from typing import Any, Callable, Dict, List, Tuple
+from django.core.management.base import BaseCommand, CommandParser
+from django.conf import settings
+
+from common.utils import get_current_domain
+
+from gitlab import Gitlab
+from gitlab.v4.objects import Group, Project
+from gitlab.exceptions import GitlabGetError
+
+import arxiv
+import requests
+import tarfile
+from base64 import b64encode
+
+
+from production.models import ProofsRepository
+from production.constants import (
+    PROOFS_REPO_UNINITIALIZED,
+    PROOFS_REPO_CREATED,
+    PROOFS_REPO_TEMPLATE_ONLY,
+    PROOFS_REPO_TEMPLATE_FORMATTED,
+    PROOFS_REPO_PRODUCTION_READY,
+)
+
+
+class Command(BaseCommand):
+    """
+    This command handles the creation and updating of git repositories.
+    """
+
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+
+        # Check that the global GITLAB_ROOT constant is set
+        if not hasattr(settings, "GITLAB_ROOT") or settings.GITLAB_ROOT == "":
+            raise LookupError(
+                "Constant `GITLAB_ROOT` is either not present in settings file or empty, please add it."
+            )
+
+        self.GL: Gitlab = self._instanciate_gitlab()
+
+    def add_arguments(self, parser: CommandParser) -> None:
+        parser.add_argument(
+            "--id",
+            type=int,
+            required=False,
+            help="The submission preprint identifier to handle a specific submission, leave blank to handle all",
+        )
+
+    def _instanciate_gitlab(self) -> Gitlab:
+        """
+        Test the connection to the git server, returns a Gitlab object.
+        """
+
+        if not hasattr(settings, "GITLAB_KEY") or settings.GITLAB_KEY == "":
+            raise LookupError(
+                "Constant `GITLAB_KEY` is either not present in secret file or empty, please add it."
+            )
+
+        if not hasattr(settings, "GITLAB_URL") or settings.GITLAB_URL == "":
+            raise LookupError(
+                "Constant `GITLAB_URL` is either not present in secret file or empty, please add it."
+            )
+
+        GL = Gitlab(
+            url="https://" + settings.GITLAB_URL,
+            private_token=settings.GITLAB_KEY,
+        )
+
+        try:
+            GL.auth()
+        except Exception as e:
+            raise AssertionError(
+                "Could not authenticate with GitLab, please check your credentials."
+            ) from e
+
+        return GL
+
+    def _get_or_create_nested_group(self, group_path: str) -> Group:
+        """
+        Create a new group on the git server based on a path of nested folders.
+        """
+        parent_group = None
+        group_path_segments = group_path.split("/")
+
+        # Traverse the path segments (up to the second-to-last one)
+        # and create the groups if they do not exist
+        for i, group_path_segment in enumerate(group_path_segments[:-1]):
+            path_up_to_segment_i = "/".join(group_path_segments[: i + 1])
+
+            # Check if group exists in the server
+            try:
+                group = self.GL.groups.get(path_up_to_segment_i)
+
+            # If it does not exist, create it
+            except GitlabGetError:
+                # Guard against the root group not existing
+                if parent_group is None:
+                    raise AssertionError(
+                        f"The parent group of {path_up_to_segment_i} does not exist. "
+                        "This should not happen normally (and would not be fixable "
+                        "because GitLab does not allow root groups to be created)."
+                    )
+
+                # Create the group
+                group = self.GL.groups.create(
+                    {
+                        "name": group_path_segment,
+                        "path": group_path_segment,
+                        "parent_id": parent_group.id,
+                        "visibility": "private",
+                    }
+                )
+
+            # Set the parent group to the current group
+            parent_group = group
+
+        return group
+
+    def _create_git_repo(self, repo: ProofsRepository):
+        """
+        Create a new git repository for the submission.
+        """
+        # Check if repo exists in the server
+        try:
+            project = self.GL.projects.get(repo.git_path)
+
+        # Create the repo on the server
+        except GitlabGetError:
+            # Get the namespace id
+            parent_group_id = self._get_or_create_nested_group(repo.git_path).id
+            project = self.GL.projects.create(
+                {
+                    "name": repo.name,
+                    "namespace_id": parent_group_id,
+                    "visibility": "private",
+                    "description": "Proofs for https://{domain}/submissions/{preprint_id}".format(
+                        domain=get_current_domain(),
+                        preprint_id=repo.stream.submission.preprint.identifier_w_vn_nr,
+                    ),
+                }
+            )
+
+        self.stdout.write(
+            self.style.SUCCESS(f"Created git repository at {repo.git_path}")
+        )
+
+    def _get_project_cloning_actions(self, project: Project) -> List[Dict[str, Any]]:
+        """
+        Return a list of gitlab actions required to fully clone a project.
+        """
+        filenames = list(
+            map(lambda x: x["path"], project.repository_tree(get_all=True))
+        )
+
+        actions = []
+        for filename in filenames:
+            try:
+                file = project.files.get(file_path=filename, ref="main")
+            except:
+                self.stdout.write(
+                    self.style.WARNING(f"File {filename} not found in {project.name}")
+                )
+                continue
+
+            actions.append(
+                {
+                    "action": "create",
+                    "file_path": filename,
+                    "content": file.content,
+                    "encoding": "base64",
+                }
+            )
+
+        return actions
+
+    def _copy_pure_templates(self, repo: ProofsRepository):
+        """
+        Copy the pure templates to the repo.
+        """
+        project = self.GL.projects.get(repo.git_path)
+
+        journal_template_project = self.GL.projects.get(repo.template_path)
+        base_template_project = self.GL.projects.get(
+            "{ROOT}/Templates/Base".format(ROOT=settings.GITLAB_ROOT)
+        )
+
+        base_actions = self._get_project_cloning_actions(base_template_project)
+        journal_actions = self._get_project_cloning_actions(journal_template_project)
+
+        # Commit the actions
+        project.commits.create(
+            {
+                "branch": "main",
+                "commit_message": "copy pure templates",
+                "actions": base_actions + journal_actions,
+            }
+        )
+
+        self.stdout.write(
+            self.style.SUCCESS(f"Copied pure templates to {repo.git_path}")
+        )
+
+    def _format_skeleton(self, repo: ProofsRepository):
+        """
+        Format the Skeleton.tex file of the repo to include basic information about the submission.
+        """
+
+        SHAPES = ["star", "dagger", "ddagger", "circ", "S", "P", "parallel"]
+        SLASH = "\\"
+        NEWLINE = f"{SLASH}{SLASH}"
+
+        def abbreviate_author(author: str) -> str:
+            """
+            Abbreviate an author's name by taking the first letter\
+            of their first and middle names, and their full last name.
+            """
+
+            # TODO: This is somewhat naive, but it should work for now.
+            first_name, *middle_names, last_name = author.split(" ")
+            # Ideally, I would like to search for matching authors in the database
+            # and abbreviate their names accordingly to the journal's style.
+            # Right now, I abbreviate only the very first name and leave the rest as is.
+
+            # Map each part of the (optionally) hyphenated first name to its abbreviation
+            # (e.g. "John-Edward" -> "J.-E.")
+            first_name_hyphen_parts = first_name.split("-")
+            first_name_hyphen_parts_abbrev = list(
+                map(lambda x: x[0].upper() + ".", first_name_hyphen_parts)
+            )
+
+            # Add different name parts to the abbreviation, glue them together with space
+            # (e.g. "John-Edward Brown Smith" -> "J.-E. Brown Smith")
+            abbreviation_parts = [
+                "-".join(first_name_hyphen_parts_abbrev),  # Abbreviated first name
+                *middle_names,
+                last_name,
+            ]
+
+            return " ".join(abbreviation_parts)
+
+        # Define the formatting functions
+        def format_authors(authors: List[str]) -> str:
+            *other_authors, last_author = authors
+
+            if len(other_authors) == 0:
+                return last_author
+            else:
+                return ", ".join(other_authors) + " and " + last_author
+
+        def format_title(title: str) -> str:
+            return title + NEWLINE
+
+        def format_copyright(authors: List[str]) -> str:
+            """
+            Format the copyright statement depending on the number\
+            of authors in the submission:
+            - 1 author: "© Author"
+            - 2 authors: "© Author1 and Author2"
+            - 3+ authors: "© Author1 et al"
+            """
+            if len(authors) == 1:
+                return f"Copyright {authors[0]}"
+            elif len(authors) == 2:
+                return f"Copyright {authors[0]} and {authors[1]}"
+            else:
+                return f"Copyright {authors[0]} {{{SLASH}it et al}}"
+
+        def format_emails(authors: List[str]) -> str:
+            """
+            Format the emails of the authors in the submission, grouped by 3 per line.\
+            The emails are padded with \\quad spacing and are prepended with a shape.
+            """
+            # Create a list array of emails, grouped by 3
+            mail_lines = [[]]
+            mail_line_i = 0
+            for i, (_, shape) in enumerate(zip(authors, cycle(SHAPES))):
+                mail_lines[mail_line_i].append(
+                    f"${SLASH}{shape}$ {SLASH}href{{mailto:email{i+1}}}{{{SLASH}small email{i+1}}}"
+                )
+
+                # Create a new mail group every 3 emails
+                if (i + 1) % 3 == 0:
+                    mail_line_i += 1
+                    mail_lines.append([])
+
+            # Flatten the inner lists and join them with "\,,\quad"
+            flattened_mail_lines = [
+                f"{SLASH},,{SLASH}quad\n".join(line) for line in mail_lines
+            ]
+
+            # Join the lines with "\,,\\"
+            flattened_mails = f"{SLASH},,{NEWLINE}\n".join(flattened_mail_lines)
+
+            return flattened_mails
+
+        def format_affiliations(authors: List[str]) -> str:
+            """
+            Format the affiliations of the authors in the submission,
+            by including the author's name and the affiliation number.
+            There is one affiliation per author by default.
+            """
+            affiliations = []
+            for i, author in enumerate(authors):
+                affiliations += [f"{{{SLASH}bf {i+1}}} Affiliation {author}"]
+
+            return f"\n{NEWLINE}\n".join(affiliations)
+
+        def format_date_human_readable(date: datetime) -> str:
+            """
+            Format a date in a human-readable format (DD-MM-YYY).
+            """
+            return date.strftime("%d-%m-%Y")
+
+        project = self.GL.projects.get(repo.git_path)
+        project_filenames = list(
+            map(lambda x: x["path"], project.repository_tree(get_all=True))
+        )
+
+        skeleton_filename = next(
+            filter(lambda x: x.endswith("Skeleton.tex"), project_filenames)
+        )
+        skeleton_file = project.files.get(file_path=skeleton_filename, ref="main")
+        skeleton_content = skeleton_file.decode().decode("utf-8")
+
+        # Collect the information about the paper
+        paper_title = repo.stream.submission.title
+        paper_abbreviated_authors = list(
+            map(abbreviate_author, repo.stream.submission.authors_as_list)
+        )
+        paper_received_date = repo.stream.submission.original_submission_date
+        paper_acceptance_date = repo.stream.submission.acceptance_date
+
+        # Create the replacement dictionary from placeholders and information
+        # key = placeholder, value = (formatting_function, *args)
+        replacements_dict = {
+            "<|TITLE|>": (format_title, paper_title),
+            "<|AUTHORS|>": (format_authors, repo.stream.submission.authors_as_list),
+            "<|EMAILS|>": (format_emails, paper_abbreviated_authors),
+            "<|COPYRIGHT|>": (format_copyright, paper_abbreviated_authors),
+            "<|AFFILIATIONS|>": (format_affiliations, paper_abbreviated_authors),
+            "<|RECEIVED|>": (format_date_human_readable, paper_received_date),
+            "<|ACCEPTED|>": (format_date_human_readable, paper_acceptance_date),
+        }
+
+        # Define a helper function to try to format and replace a placeholder
+        # which catches any errors and prints them to the console non-intrusively
+        def try_format_replace(
+            text: str,
+            key: str,
+            value: Tuple[Callable[[Any], str], Any],
+        ):
+            try:
+                formatting_function, *args = value
+                formatted_value = formatting_function(*args)
+                return text.replace(key, formatted_value)
+            except:
+                self.stdout.write(
+                    self.style.ERROR(
+                        f"Could not format and replace {key} with {value} in {repo.git_path}"
+                    )
+                )
+                return text
+
+        # Replace the placeholders with the submission information
+        # by iteratively applying the formatting functions to the skeleton
+        skeleton_content = reduce(
+            lambda text, replace_pair: try_format_replace(text, *replace_pair),
+            replacements_dict.items(),
+            skeleton_content,
+        )
+
+        # Commit the changes to the skeleton file and change its name
+        project.commits.create(
+            {
+                "branch": "main",
+                "commit_message": f"format skeleton file",
+                "actions": [
+                    {
+                        "action": "move",
+                        "content": skeleton_content,
+                        "previous_path": skeleton_filename,
+                        # Change the "Skeleton" part from the filename to the repo name
+                        # and remove the extraneous "scipost_" label from the identifier slug
+                        "file_path": skeleton_filename.replace(
+                            "Skeleton",
+                            repo.name.replace("scipost_", ""),
+                        ),
+                    },
+                ],
+            }
+        )
+
+        self.stdout.write(
+            self.style.SUCCESS(
+                f"Successfully formatted the skeleton of {repo.git_path}"
+            )
+        )
+
+    def _copy_arxiv_source_files(self, repo: ProofsRepository):
+        paper = next(
+            arxiv.Search(
+                id_list=[repo.stream.submission.preprint.identifier_w_vn_nr]
+            ).results()
+        )
+        source_stream = requests.get(paper.pdf_url.replace("pdf", "src"), stream=True)
+
+        # Create file creation actions for each file in the source tar
+        actions = []
+        with tarfile.open(fileobj=source_stream.raw) as tar:
+            for member in tar:
+                if not member.isfile():
+                    continue
+
+                f = tar.extractfile(member)
+                try:
+                    bin_content = f.read()
+                    actions.append(
+                        {
+                            "action": "create",
+                            "file_path": member.name,
+                            "encoding": "base64",
+                            # Encode the binary content in base64, required by the API
+                            "content": b64encode(bin_content).decode("utf-8"),
+                        }
+                    )
+
+                except:
+                    self.stdout.write(
+                        self.style.ERROR(
+                            f"Could not read {member.name} from the arXiv source files, skipping..."
+                        )
+                    )
+
+        # Filter out the files that already exist in the repo to avoid conflicts
+        project = self.GL.projects.get(repo.git_path)
+        project_existing_filenames = list(
+            map(lambda x: x["path"], project.repository_tree(get_all=True))
+        )
+
+        non_existing_file_actions = [
+            action
+            for action in actions
+            if action["file_path"] not in project_existing_filenames
+        ]
+
+        # Commit the creation of the files
+        project.commits.create(
+            {
+                "branch": "main",
+                "commit_message": f"copy arXiv source files",
+                "actions": non_existing_file_actions,
+            }
+        )
+
+        self.stdout.write(
+            self.style.SUCCESS(
+                f"Successfully copied the author source files to {repo.git_path}"
+            )
+        )
+
+    def handle(self, *args, **options):
+        # Limit the actions to a specific submission if requested
+        if preprint_id := options.get("id"):
+            repos = ProofsRepository.objects.filter(
+                stream__submission__preprint__identifier_w_vn_nr=preprint_id
+            )
+        else:
+            repos = ProofsRepository.objects.all()
+
+        # Create the repos
+        repos_to_be_created = repos.filter(status=PROOFS_REPO_UNINITIALIZED)
+        for repo in repos_to_be_created:
+            try:
+                self._create_git_repo(repo)
+                repo.status = PROOFS_REPO_CREATED
+                repo.save()
+            except Exception as e:
+                self.stdout.write(
+                    self.style.ERROR(
+                        f"Could not create the git repo for {repo.git_path}, error: {e}"
+                    )
+                )
+
+        # Copy the pure templates
+        repos_to_be_templated = repos.filter(status=PROOFS_REPO_CREATED)
+        for repo in repos_to_be_templated:
+            try:
+                self._copy_pure_templates(repo)
+                repo.status = PROOFS_REPO_TEMPLATE_ONLY
+                repo.save()
+            except Exception as e:
+                self.stdout.write(
+                    self.style.ERROR(
+                        f"Could not copy the pure templates to {repo.git_path}, error: {e}"
+                    )
+                )
+
+        # Format the skeleton files
+        repos_to_be_formatted = repos.filter(status=PROOFS_REPO_TEMPLATE_ONLY)
+        for repo in repos_to_be_formatted:
+            try:
+                self._format_skeleton(repo)
+                repo.status = PROOFS_REPO_TEMPLATE_FORMATTED
+                repo.save()
+            except Exception as e:
+                self.stdout.write(
+                    self.style.ERROR(
+                        f"Could not format the skeleton of {repo.git_path}, error: {e}"
+                    )
+                )
+
+        # Copy the arXiv source files
+        repos_to_be_copied = repos.filter(status=PROOFS_REPO_TEMPLATE_FORMATTED)
+        for repo in repos_to_be_copied:
+            try:
+                if "arxiv.org" in repo.stream.submission.preprint.url:
+                    self._copy_arxiv_source_files(repo)
+                    repo.status = PROOFS_REPO_PRODUCTION_READY
+                    repo.save()
+            except Exception as e:
+                self.stdout.write(
+                    self.style.ERROR(
+                        f"Could not copy the arXiv source files to {repo.git_path}, error: {e}"
+                    )
+                )
diff --git a/scipost_django/production/migrations/0006_proofsrepository.py b/scipost_django/production/migrations/0006_proofsrepository.py
new file mode 100644
index 0000000000000000000000000000000000000000..8865b3441e8fe8335de26d6a681dd95d8a99a440
--- /dev/null
+++ b/scipost_django/production/migrations/0006_proofsrepository.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.18 on 2023-05-15 14:25
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('production', '0005_auto_20190511_1141'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProofsRepository',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('status', models.CharField(choices=[('uninitialized', 'The repository does not exist'), ('created', 'The repository exists but is empty'), ('template_only', 'The repository contains the bare template'), ('template_formatted', 'The repository contains the automatically formatted template'), ('production_ready', 'The repository is ready for production')], default='uninitialized', max_length=32)),
+                ('stream', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='proofs_repository', to='production.productionstream')),
+            ],
+            options={
+                'verbose_name_plural': 'proofs repositories',
+            },
+        ),
+    ]
diff --git a/scipost_django/production/models.py b/scipost_django/production/models.py
index 65ef7165884446c0f51b4fe9e3d1ab2cafd7c388..e168ea5a5b91bd1c412399caf30e4227097d2f4a 100644
--- a/scipost_django/production/models.py
+++ b/scipost_django/production/models.py
@@ -6,8 +6,14 @@ from django.db import models
 from django.contrib.contenttypes.fields import GenericRelation
 from django.urls import reverse
 from django.contrib.auth.models import User
+from profiles.models import Profile
 from django.utils import timezone
 from django.utils.functional import cached_property
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.db.models import Value
+from django.db.models.functions import Concat
+from django.conf import settings
 
 from .constants import (
     PRODUCTION_STREAM_STATUS,
@@ -126,17 +132,11 @@ class ProductionStream(models.Model):
 
 
 class ProductionEvent(models.Model):
-    stream = models.ForeignKey(
-        ProductionStream, on_delete=models.CASCADE, related_name="events"
-    )
-    event = models.CharField(
-        max_length=64, choices=PRODUCTION_EVENTS, default=EVENT_MESSAGE
-    )
+    stream = models.ForeignKey(ProductionStream, on_delete=models.CASCADE, related_name="events")
+    event = models.CharField(max_length=64, choices=PRODUCTION_EVENTS, default=EVENT_MESSAGE)
     comments = models.TextField(blank=True, null=True)
     noted_on = models.DateTimeField(default=timezone.now)
-    noted_by = models.ForeignKey(
-        "production.ProductionUser", on_delete=models.CASCADE, related_name="events"
-    )
+    noted_by = models.ForeignKey("production.ProductionUser", on_delete=models.CASCADE, related_name="events")
     noted_to = models.ForeignKey(
         "production.ProductionUser",
         on_delete=models.CASCADE,
@@ -159,10 +159,7 @@ class ProductionEvent(models.Model):
 
     @cached_property
     def editable(self):
-        return (
-            self.event in [EVENT_MESSAGE, EVENT_HOUR_REGISTRATION]
-            and not self.stream.completed
-        )
+        return self.event in [EVENT_MESSAGE, EVENT_HOUR_REGISTRATION] and not self.stream.completed
 
 
 def production_event_upload_location(instance, filename):
@@ -185,9 +182,7 @@ class ProductionEventAttachment(models.Model):
         on_delete=models.CASCADE,
         related_name="attachments",
     )
-    attachment = models.FileField(
-        upload_to=production_event_upload_location, storage=SecureFileStorage()
-    )
+    attachment = models.FileField(upload_to=production_event_upload_location, storage=SecureFileStorage())
 
     def get_absolute_url(self):
         return reverse(
@@ -213,20 +208,12 @@ class Proofs(models.Model):
     Proofs are directly related to a ProductionStream and Submission in SciPost.
     """
 
-    attachment = models.FileField(
-        upload_to=proofs_upload_location, storage=SecureFileStorage()
-    )
+    attachment = models.FileField(upload_to=proofs_upload_location, storage=SecureFileStorage())
     version = models.PositiveSmallIntegerField(default=0)
-    stream = models.ForeignKey(
-        "production.ProductionStream", on_delete=models.CASCADE, related_name="proofs"
-    )
-    uploaded_by = models.ForeignKey(
-        "production.ProductionUser", on_delete=models.CASCADE, related_name="+"
-    )
+    stream = models.ForeignKey("production.ProductionStream", on_delete=models.CASCADE, related_name="proofs")
+    uploaded_by = models.ForeignKey("production.ProductionUser", on_delete=models.CASCADE, related_name="+")
     created = models.DateTimeField(auto_now_add=True)
-    status = models.CharField(
-        max_length=16, choices=PROOFS_STATUSES, default=PROOFS_UPLOADED
-    )
+    status = models.CharField(max_length=16, choices=PROOFS_STATUSES, default=PROOFS_UPLOADED)
     accessible_for_authors = models.BooleanField(default=False)
 
     objects = ProofsQuerySet.as_manager()
@@ -239,9 +226,7 @@ class Proofs(models.Model):
         return reverse("production:proofs_pdf", kwargs={"slug": self.slug})
 
     def __str__(self):
-        return "Proofs {version} for Stream {stream}".format(
-            version=self.version, stream=self.stream.submission.title
-        )
+        return "Proofs {version} for Stream {stream}".format(version=self.version, stream=self.stream.submission.title)
 
     def save(self, *args, **kwargs):
         # Control Report count per Submission.
@@ -252,3 +237,156 @@ class Proofs(models.Model):
     @property
     def slug(self):
         return proofs_id_to_slug(self.id)
+
+
+class ProofsRepository(models.Model):
+    """
+    ProofsRepository is a GitLab repository of Proofs for a Submission.
+    """
+
+    PROOFS_REPO_UNINITIALIZED = "uninitialized"
+    PROOFS_REPO_CREATED = "created"
+    PROOFS_REPO_TEMPLATE_ONLY = "template_only"
+    PROOFS_REPO_TEMPLATE_FORMATTED = "template_formatted"
+    PROOFS_REPO_PRODUCTION_READY = "production_ready"
+    PROOFS_REPO_STATUSES = (
+        (PROOFS_REPO_UNINITIALIZED, "The repository does not exist"),
+        (PROOFS_REPO_CREATED, "The repository exists but is empty"),
+        (PROOFS_REPO_TEMPLATE_ONLY, "The repository contains the bare template"),
+        (
+            PROOFS_REPO_TEMPLATE_FORMATTED,
+            "The repository contains the automatically formatted template",
+        ),
+        (PROOFS_REPO_PRODUCTION_READY, "The repository is ready for production"),
+    )
+
+    stream = models.OneToOneField(
+        ProductionStream,
+        on_delete=models.CASCADE,
+        related_name="proofs_repository",
+    )
+    status = models.CharField(
+        max_length=32,
+        choices=PROOFS_REPO_STATUSES,
+        default=PROOFS_REPO_UNINITIALIZED,
+    )
+
+    @property
+    def name(self) -> str:
+        """
+        Return the name of the repository in the form of "id_lastname".
+        """
+        # Get the last name of the first author by getting the first author string from the submission
+        first_author_str = self.stream.submission.authors_as_list[0]
+        first_author_profile = (
+            Profile.objects.annotate(
+                full_name=Concat("first_name", Value(" "), "last_name")
+            )
+            .filter(full_name=first_author_str)
+            .first()
+        )
+        if first_author_profile is None:
+            first_author_last_name = first_author_str.split(" ")[-1]
+        else:
+            first_author_last_name = first_author_profile.last_name
+            # Keep only the last of the last names
+            first_author_last_name = first_author_last_name.split(" ")[-1]
+
+        return "{preprint_id}_{last_name}".format(
+            preprint_id=self.stream.submission.preprint.identifier_w_vn_nr,
+            last_name=first_author_last_name,
+        )
+
+    @property
+    def journal_abbrev(self) -> str:
+        # The DOI label is used to determine the path of the repository and template
+        return self.stream.submission.editorial_decision.for_journal.doi_label
+
+    @property
+    def journal_subdivision(self) -> str:
+        """
+        Return the subdivision of the repository depending on the journal type.
+        Regular journals are subdivided per year and month,
+        while proceedings are subdivided per year and conference.
+        """
+
+        # TODO: Removing the whitespace should be more standardised
+        # Refactor: journal and year are common to both cases
+        # perhaps it is best to only return the subdivision month/conference
+        if proceedings_issue := self.stream.submission.proceedings:
+            return "{journal}/{year}/{conference}".format(
+                journal=self.journal_abbrev,
+                year=self.stream.submission.proceedings.event_end_date.year,
+                conference=proceedings_issue.event_suffix.replace(" ", ""),
+            )
+        else:
+            # Get creation date of the stream
+            # Warning: The month grouping of streams was done using the tasked date,
+            # but should now instead be the creation (opened) date.
+            opened_year, opened_month = self.stream.opened.strftime("%Y-%m").split("-")
+
+            return "{journal}/{year}/{month}".format(
+                journal=self.journal_abbrev,
+                year=opened_year,
+                month=opened_month,
+            )
+
+    @property
+    def git_path(self) -> str:
+        return "{ROOT}/Proofs/{journal_subdivision}/{repo_name}".format(
+            ROOT=settings.GITLAB_ROOT,
+            journal_subdivision=self.journal_subdivision,
+            repo_name=self.name,
+        )
+
+    @property
+    def git_url(self) -> str:
+        return "https://{GITLAB_URL}/{git_path}".format(
+            GITLAB_URL=settings.GITLAB_URL,
+            git_path=self.git_path,
+        )
+
+    @property
+    def git_ssh_clone_url(self) -> str:
+        return "git:{GITLAB_URL}/{git_path}.git".format(
+            GITLAB_URL=settings.GITLAB_URL,
+            git_path=self.git_path,
+        )
+
+    @property
+    def template_path(self) -> str:
+        """
+        Return the path to the template repository.
+        """
+        if self.stream.submission.proceedings is not None:
+            return "{ROOT}/Templates/{journal_subdivision}".format(
+                ROOT=settings.GITLAB_ROOT,
+                journal_subdivision=self.journal_subdivision,
+            )
+        else:
+            return "{ROOT}/Templates/{journal}".format(
+                ROOT=settings.GITLAB_ROOT,
+                journal=self.journal_abbrev,
+            )
+
+    def __str__(self) -> str:
+        return f"Proofs repo for {self.stream}"
+
+    class Meta:
+        verbose_name_plural = "proofs repositories"
+
+
+@receiver(post_save, sender=ProductionStream)
+def production_stream_create_proofs_repo(sender, instance, created, **kwargs):
+    """
+    When a ProductionStream instance is created, a ProofsRepository instance is created
+    and linked to it.
+    """
+    if created:
+        ProofsRepository.objects.create(
+            stream=instance,
+            status=ProofsRepository.PROOFS_REPO_UNINITIALIZED,
+        )
+
+
+post_save.connect(production_stream_create_proofs_repo, sender=ProductionStream)
diff --git a/scipost_django/production/tests/test_models.py b/scipost_django/production/tests/test_models.py
index ddef03c4df91383dfce15a034976e3a469d77d70..e35c6d72e48182c4ed96b86204e5deeeaf51c62d 100644
--- a/scipost_django/production/tests/test_models.py
+++ b/scipost_django/production/tests/test_models.py
@@ -1,7 +1,231 @@
 __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
 __license__ = "AGPL v3"
 
+import datetime
 
 from django.test import TestCase
 
 # Create your tests here.
+
+from submissions.constants import EIC_REC_PUBLISH
+from journals.models import Journal, Issue
+from submissions.models import Submission, EditorialDecision
+from production.models import ProductionStream, ProofsRepository
+from preprints.models import Preprint
+from ontology.models import AcademicField, Branch, Specialty
+from colleges.models import College
+from scipost.models import Contributor
+from profiles.models import Profile
+from proceedings.models import Proceedings
+
+from django.contrib.auth.models import User
+from django.conf import settings
+
+
+class TestProofRepository(TestCase):
+    def _create_submitter_contributor(self):
+        random_user = User.objects.create_user(
+            username="testuser",
+            password="testpassword",
+        )
+        user_profile = Profile.objects.create(
+            title="DR",
+            first_name="Test",
+            last_name="User",
+        )
+        Contributor.objects.create(user=random_user, profile=user_profile)
+
+    def _create_college(self):
+        College.objects.create(
+            name="College of Quantum Physics",
+            acad_field=AcademicField.objects.get(name="Quantum Physics"),
+            slug="college-of-quantum-physics",
+            order=10,
+        )
+
+    def _create_journal(self):
+        Journal.objects.create(
+            college=College.objects.get(name="College of Quantum Physics"),
+            name="SciPost Physics",
+            name_abbrev="SciPost Phys.",
+            doi_label="SciPostPhys",
+            cf_metrics='{"":""}',
+        )
+
+    def _create_editorial_decision(self):
+        EditorialDecision.objects.create(
+            submission=Submission.objects.get(
+                preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+            ),
+            for_journal=Journal.objects.get(name="SciPost Physics"),
+            decision=EIC_REC_PUBLISH,
+            status=EditorialDecision.FIXED_AND_ACCEPTED,
+        )
+
+    def _create_specialty(self):
+        Specialty.objects.create(
+            acad_field=AcademicField.objects.get(name="Quantum Physics"),
+            name="Quantum Information",
+            slug="quantum-information",
+            order=10,
+        )
+
+    def _create_academic_field(self):
+        AcademicField.objects.create(
+            branch=Branch.objects.get(name="Physics"),
+            name="Quantum Physics",
+            slug="quantum-physics",
+            order=10,
+        )
+
+    def _create_branch(self):
+        Branch.objects.create(
+            name="Physics",
+            slug="physics",
+            order=10,
+        )
+
+    def _create_preprint(self):
+        Preprint.objects.create(identifier_w_vn_nr="scipost_202101_00001v1")
+
+    def _create_submission(self):
+        submission = Submission.objects.create(
+            preprint=Preprint.objects.get(identifier_w_vn_nr="scipost_202101_00001v1"),
+            submitted_to=Journal.objects.get(name="SciPost Physics"),
+            title="Test submission",
+            abstract="Test abstract",
+            author_list="Test User",
+            acad_field=AcademicField.objects.get(name="Quantum Physics"),
+            # specialties=Specialty.objects.filter(name="Quantum Information"),
+            submitted_by=Contributor.objects.get(user__username="testuser"),
+        )
+        submission.authors.add(Contributor.objects.get(user__username="testuser"))
+        submission.save()
+
+    def _create_production_stream(self):
+        stream = ProductionStream.objects.create(
+            submission=Submission.objects.get(
+                preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+            ),
+        )
+        stream.opened = datetime.datetime(
+            2021, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        stream.save()
+
+    def setUp(self):
+        self._create_submitter_contributor()
+        self._create_branch()
+        self._create_academic_field()
+        self._create_specialty()
+        self._create_college()
+        self._create_journal()
+        self._create_preprint()
+        self._create_submission()
+        self._create_editorial_decision()
+        self._create_production_stream()
+
+    def test_repo_name_existing_profile(self):
+        proofs_repo = ProofsRepository.objects.get(
+            stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        self.assertEqual(proofs_repo.name, "scipost_202101_00001v1_User")
+
+    def test_repo_name_nonexisting_profile(self):
+        proofs_repo = ProofsRepository.objects.get(
+            stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        # delete profile
+        Contributor.objects.get(user__username="testuser").profile.delete()
+
+        self.assertEqual(proofs_repo.name, "scipost_202101_00001v1_User")
+
+    def test_repo_name_double_last_name_profile(self):
+        proofs_repo = ProofsRepository.objects.get(
+            stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        proofs_repo.stream.submission.author_list = "Test Usable User"
+
+        user_profile = Contributor.objects.get(user__username="testuser").profile
+        user_profile.last_name = "Usable User"
+        user_profile.save()
+
+        self.assertEqual(proofs_repo.name, "scipost_202101_00001v1_User")
+
+    def test_repo_name_two_authors(self):
+        proofs_repo = ProofsRepository.objects.get(
+            stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        proofs_repo.stream.submission.author_list = (
+            "Another Personable Person, Test Usable User"
+        )
+
+        self.assertEqual(proofs_repo.name, "scipost_202101_00001v1_Person")
+
+    def test_repo_paths_scipostphys(self):
+        proofs_repo = ProofsRepository.objects.get(
+            stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        settings.GITLAB_ROOT = "ProjectRoot"
+
+        self.assertEqual(
+            proofs_repo.git_path,
+            "ProjectRoot/Proofs/SciPostPhys/2021/01/scipost_202101_00001v1_User",
+        )
+
+        self.assertEqual(
+            proofs_repo.template_path,
+            "ProjectRoot/Templates/SciPostPhys",
+        )
+
+    def test_repo_paths_scipostphysproc(self):
+        proofs_repo = ProofsRepository.objects.get(
+            stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        journal = Journal.objects.get(name="SciPost Physics")
+        journal.name = "SciPost Physics Proceedings"
+        journal.doi_label = "SciPostPhysProc"
+        journal.structure = "IO"  # proceedings, as Issues Only
+        journal.save()
+
+        issue = Issue.objects.create(
+            in_journal=journal,
+            number=1,
+            slug="proc-1",
+            doi_label="SciPostPhysProc.1",
+        )
+
+        proceedings = Proceedings.objects.create(
+            issue=issue,
+            submissions_open=datetime.datetime.now(),
+            submissions_close=datetime.datetime.now(),
+            submissions_deadline=datetime.datetime.now(),
+            event_end_date=datetime.datetime(2021, 5, 5),
+            event_start_date=datetime.datetime(2021, 5, 1),
+            event_suffix="ProcName21",
+        )
+
+        submission = Submission.objects.get(
+            preprint__identifier_w_vn_nr="scipost_202101_00001v1"
+        )
+
+        submission.proceedings = proceedings
+        submission.save()
+
+        settings.GITLAB_ROOT = "ProjectRoot"
+
+        self.assertEqual(
+            proofs_repo.git_path,
+            "ProjectRoot/Proofs/SciPostPhysProc/2021/ProcName21/scipost_202101_00001v1_User",
+        )
+
+        self.assertEqual(
+            proofs_repo.template_path,
+            "ProjectRoot/Templates/SciPostPhysProc/2021/ProcName21",
+        )
diff --git a/scipost_django/scipost/management/commands/vet_superusers.py b/scipost_django/scipost/management/commands/vet_superusers.py
new file mode 100644
index 0000000000000000000000000000000000000000..c790fecf8256872aa2da80bf43e834b6545128a2
--- /dev/null
+++ b/scipost_django/scipost/management/commands/vet_superusers.py
@@ -0,0 +1,18 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth.models import User, Group
+
+class Command(BaseCommand):
+
+    def handle(self, *args, **options):
+        superusers = User.objects.filter(is_superuser=True)
+        admin_group = Group.objects.get(name='SciPost Administrators')
+
+        for superuser in superusers:
+            superuser.groups.add(admin_group)
+            superuser.save()
+
+        self.stdout.write(self.style.SUCCESS(f"Successfully vetted {len(superusers)} superusers."))
\ No newline at end of file
diff --git a/scipost_django/submissions/factories/assignment.py b/scipost_django/submissions/factories/assignment.py
index d0d0fca6a20d3acdd6162157457b3e33da182198..5bc513f62337cf6be1d1e3bc3981c42defd9890c 100644
--- a/scipost_django/submissions/factories/assignment.py
+++ b/scipost_django/submissions/factories/assignment.py
@@ -5,7 +5,7 @@ __license__ = "AGPL v3"
 import factory
 
 from scipost.models import Contributor
-from submissions.constants import ASSIGNMENT_STATUSES
+from submissions.models.submission import Submission
 from submissions.models import EditorialAssignment
 
 
@@ -18,7 +18,7 @@ class EditorialAssignmentFactory(factory.django.DjangoModelFactory):
 
     submission = None
     to = factory.Iterator(Contributor.objects.all())
-    status = factory.Iterator(ASSIGNMENT_STATUSES, getter=lambda c: c[0])
+    status = factory.Iterator(Submission.SUBMISSION_STATUSES, getter=lambda c: c[0])
     date_created = factory.lazy_attribute(lambda o: o.submission.latest_activity)
     date_answered = factory.lazy_attribute(lambda o: o.submission.latest_activity)
 
diff --git a/scipost_django/submissions/factories/referee_invitation.py b/scipost_django/submissions/factories/referee_invitation.py
index 565387f4069dfb3a1eb7d250c5e609231b47afe4..3f303de013b8eec7ff19d96d4d47f244ee6731f0 100644
--- a/scipost_django/submissions/factories/referee_invitation.py
+++ b/scipost_django/submissions/factories/referee_invitation.py
@@ -4,6 +4,7 @@ __license__ = "AGPL v3"
 
 import factory
 import pytz
+import random
 
 from faker import Faker
 
@@ -46,6 +47,7 @@ class AcceptedRefereeInvitationFactory(RefereeInvitationFactory):
     @factory.post_generation
     def report(self, create, extracted, **kwargs):
         if create:
+            from submissions.factories import VettedReportFactory
             VettedReportFactory(submission=self.submission, author=self.referee)
 
 
diff --git a/scipost_django/submissions/factories/submission.py b/scipost_django/submissions/factories/submission.py
index 954fd91956f2bb754df526e26b8cb8187a60e470..a8af6df0b59784568ec151e9a2e7d3280bba169d 100644
--- a/scipost_django/submissions/factories/submission.py
+++ b/scipost_django/submissions/factories/submission.py
@@ -4,6 +4,7 @@ __license__ = "AGPL v3"
 
 import factory
 import pytz
+import random
 
 from faker import Faker
 
@@ -308,6 +309,11 @@ class PublishedSubmissionFactory(InRefereeingSubmissionFactory):
 
     @factory.post_generation
     def referee_invites(self, create, extracted, **kwargs):
+        from submissions.factories import (
+            FulfilledRefereeInvitationFactory,
+            CancelledRefereeInvitationFactory
+        )
+        
         for i in range(random.randint(2, 4)):
             FulfilledRefereeInvitationFactory(submission=self)
         for i in range(random.randint(0, 2)):