diff --git a/.dev-js/package.json b/.dev-js/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..f1a3b8b143115ffb8f0fee2d452ec2830cd888a3
--- /dev/null
+++ b/.dev-js/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "aleksis-builddeps",
+  "version": "1.0.0",
+  "dependencies": {
+    "@intlify/eslint-plugin-vue-i18n": "^2.0.0",
+    "eslint": "^8.26.0",
+    "eslint-config-prettier": "^9.0.0",
+    "eslint-plugin-vue": "^9.7.0",
+    "prettier": "^3.0.0",
+    "stylelint": "^15.0.0",
+    "stylelint-config-prettier": "^9.0.3",
+    "stylelint-config-standard": "^34.0.0"
+  }
+}
diff --git a/aleksis/apps/cursus/frontend/components/SubjectChip.vue b/aleksis/apps/cursus/frontend/components/SubjectChip.vue
index 266fae9eb345c0ef88cc36b3263624e8e6b2db4a..a341d1873303ed81aa5d5484ea38f213e30f10d8 100644
--- a/aleksis/apps/cursus/frontend/components/SubjectChip.vue
+++ b/aleksis/apps/cursus/frontend/components/SubjectChip.vue
@@ -11,6 +11,11 @@ export default {
       required: false,
       default: false,
     },
+    appendIcon: {
+      type: String,
+      default: null,
+      required: false,
+    },
   },
 };
 </script>
@@ -18,5 +23,6 @@ export default {
 <template>
   <v-chip :color="subject.colourBg" :text-color="subject.colourFg">
     {{ shortName ? subject.shortName : subject.name }}
+    <v-icon right v-if="appendIcon">{{ appendIcon }}</v-icon>
   </v-chip>
 </template>
diff --git a/aleksis/apps/cursus/schema.py b/aleksis/apps/cursus/schema.py
index a1e05e811e28bdac0936ba5a1c7b6d71d992a705..33ed506581ae9c5e1d2e5b512818769988a61713 100644
--- a/aleksis/apps/cursus/schema.py
+++ b/aleksis/apps/cursus/schema.py
@@ -138,7 +138,11 @@ class CourseType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
 
     @staticmethod
     def resolve_groups(root, info, **kwargs):
-        return get_objects_for_user(info.context.user, "core.view_group", root.groups.all())
+        by_permission = get_objects_for_user(
+            info.context.user, "core.view_group", root.groups.all()
+        )
+        by_ownership = info.context.user.person.owner_of.all() & root.groups.all()
+        return by_permission | by_ownership
 
     @staticmethod
     def resolve_course_id(root, info, **kwargs):
@@ -267,10 +271,7 @@ class Query(graphene.ObjectType):
     def resolve_courses_of_teacher(root, info, teacher=None):
         if not has_person(info.context.user):
             raise PermissionDenied()
-        if teacher:
-            teacher = Person.objects.get(pk=teacher)
-        else:
-            teacher = info.context.user.person
+        teacher = Person.objects.get(pk=teacher) if teacher else info.context.user.person
         # FIXME: Permission checking. But maybe it's done in get_queryset
         return teacher.courses_as_teacher.all()
 
diff --git a/pyproject.toml b/pyproject.toml
index a3880633acc0e55c1cf933ec60188fa4d5e5d57f..0a229883233684df0f867b218f33de2c27503df0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,7 @@ priority = "primary"
 name = "gitlab"
 url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple"
 priority = "supplemental"
+
 [tool.poetry.dependencies]
 python = "^3.10"
 aleksis-core = "^4.0.0.dev3"
@@ -44,19 +45,7 @@ cursus = "aleksis.apps.cursus.apps:DefaultConfig"
 django-stubs = "^4.2"
 safety = "^2.3.5"
 
-flake8 = "^6.0.0"
-flake8-django = "^1.0.0"
-flake8-fixme = "^1.1.1"
-flake8-bandit = "^4.1.1"
-flake8-builtins = "^2.0.0"
-flake8-docstrings = "^1.5.0"
-flake8-rst-docstrings = "^0.3.0"
-
-black = ">=21.0"
-flake8-black = "^0.3.0"
-
-isort = "^5.0.0"
-flake8-isort = "^6.0.0"
+ruff = "^0.1.5"
 
 curlylint = "^0.13.0"
 
@@ -79,10 +68,6 @@ sphinxcontrib-svg2pdfconverter = "^1.1.1"
 sphinx-autodoc-typehints = "^1.7"
 sphinx_material = "^0.0.35"
 
-[tool.black]
-line-length = 100
-exclude = "/migrations/"
-
 [tool.curlylint]
 include = '\.html'
 
@@ -95,6 +80,21 @@ meta_viewport = true
 no_autofocus = true
 tabindex_no_positive = true
 
+
+[tool.ruff]
+exclude = ["migrations", "tests"]
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "UP", "B", "SIM", "I", "DJ", "A", "S"]
+ignore = ["UP034", "UP015", "B028"]
+
+[tool.ruff.isort]
+known-first-party = ["aleksis"]
+section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"]
+
+[tool.ruff.isort.sections]
+django = ["django"]
 [build-system]
 requires = ["poetry-core>=1.0.0"]
 build-backend = "poetry.core.masonry.api"
diff --git a/tox.ini b/tox.ini
index 85c2494a5a2f5480bb05d48781edfaa74803eeab..294e65bc96d4e06262b67e3e6b8a987307b226e4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,11 +4,12 @@ skip_missing_interpreters = true
 envlist = py39,py310,py311
 
 [testenv]
-allowlist_externals = poetry
+allowlist_externals =
+    poetry
+    yarnpkg
 skip_install = true
-envdir = {toxworkdir}/globalenv
 commands_pre =
-     poetry install
+     poetry install --all-extras
      poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
@@ -22,14 +23,17 @@ setenv =
     TEST_HOST = {env:TEST_HOST:172.17.0.1}
 
 [testenv:lint]
+commands_pre =
+    poetry install --only=dev
+    yarnpkg --cwd=.dev-js
 commands =
-    poetry run black --check --diff aleksis/
-    poetry run isort -c --diff --stdout aleksis/
-    poetry run flake8 {posargs} aleksis/
-    poetry run sh -c "aleksis-admin yarn run prettier --check --ignore-path={toxinidir}/.prettierignore {toxinidir}"
-    poetry run sh -c "aleksis-admin yarn run eslint {toxinidir}/aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=."
+    poetry run ruff check {posargs} aleksis/
+    yarnpkg --cwd=.dev-js run prettier --ignore-path={toxinidir}/.prettierignore {posargs} --check ..
+    yarnpkg --cwd=.dev-js run eslint ../aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=.
 
 [testenv:security]
+commands_pre =
+    poetry install --all-extras
 commands =
     poetry show --no-dev
     poetry run safety check --full-report
@@ -41,33 +45,25 @@ commands_pre =
 commands = poetry build
 
 [testenv:docs]
+commands_pre =
+    poetry install --with docs
 commands = poetry run make -C docs/ html {posargs}
 
 [testenv:reformat]
+commands_pre =
+    poetry install --only=dev
+    yarnpkg --cwd=.dev-js
 commands =
-    poetry run isort aleksis/
-    poetry run black aleksis/
-    poetry run sh -c "aleksis-admin yarn run prettier --write --ignore-path={toxinidir}/.prettierignore {toxinidir}"
+    poetry run ruff format aleksis/
+    yarnpkg --cwd=.dev-js run prettier --ignore-path={toxinidir}/.prettierignore --write ..
 
 [testenv:makemessages]
+commands_pre =
+    poetry install
 commands =
     poetry run aleksis-admin makemessages --no-wrap -e html,txt,py,email -i static -l ar -l de_DE -l fr -l nb_NO -l tr_TR -l la -l uk -l ru
     poetry run aleksis-admin makemessages --no-wrap -d djangojs -i **/node_modules -l ar -l de_DE -l fr -l nb_NO -l tr_TR -l la -l uk -l ru
 
-[flake8]
-max_line_length = 100
-exclude = migrations,tests
-ignore = BLK100,E203,E231,W503,D100,D101,D102,D103,D104,D105,D106,D107,RST215,RST214,F821,F841,S106,T100,T101,DJ05
-
-[isort]
-profile = black
-line_length = 100
-default_section = THIRDPARTY
-known_first_party = aleksis
-known_django = django
-skip = migrations
-sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
-
 [pytest]
 DJANGO_SETTINGS_MODULE = aleksis.core.settings
 junit_family = legacy