Building an AJAX-based CRUD app in Flask

How to put the finishing touches on a basic web application

An illustration of the word Ajax on a background of computer code over an open laptop

Full stack development is no easy task, and even a simple application can involve a lot of moving parts. To help you get to grips with some of the processes and requirements involved, this is the final article in a three-part series that takes us through building a basic web application. 

In our case, it’s a basic app that curates details on a selection of retro video games. We’ve been using Postgres as our back-end database, and to interact with it, we’ve been using Flask, which is a lightweight framework for building web apps, supporting the Python programming language. 

In our last two tutorials on Postgres and building a basic Flask app, we started building a create-read-update-delete (CRUD) application. This is the basic model for many web apps that handle records, backed by an online relational database. We’ll be building on the work in those first two tutorials, which you can find here and here. In the final tutorial of the series, we'll flesh out our vintage video games app RetroNerds to cover all the CRUD features, using AJAX to make managing our database records a smoother process. 

Our rudimentary app from the last tutorial used Python to render data from the database directly in HTML. As our application gets more complex, this will become less and less viable. Instead, we need a framework to handle those different CRUD functions. Flask is based on a web application design pattern known as model-view-controller (MVC), and we'll use this as the basis for our app. 

Think of MVC as having three layers. At the back is the ‘model’, which is the back-end database. We spent the first tutorial in this series setting up the database and using SQL to access it. In the second, we learned how to interact with it in the object-oriented format that Python understands, using a tool called SQLAlchemy. 

The second part of the MVC framework is the controller. Think of this as a switchboard that directs different requests from the user. When we ask to create, read, update, or delete a record, it’s the controller that finds the right function to do it and then interacts with the model. 

The controller does this using routes that point to different functions. We defined the most basic route - the one that gets triggered when you visit the RetroNerds index page - in the last tutorial. Our next task is to define routes covering all the CRUD functions. 

The final part of the MVC is the view. This is the display that the user sees. It shows us our data and allows us to make requests. MVC views use templates to hold and update data dynamically. 

We'll begin by importing Flask's template rendering library, along with several others that we'll need during this tutorial. Working with the previous tutorial's source code, update line one with an expanded list of packages: 

from flask import Flask, render_template, request, redirect, url_for, jsonify

Now replace the def index(): function in the app.route('/') section with this simplified code: 

def index():

        return render_template('index.html', games=Game.query.all())

Instead of looping through the results of our database query and generating the HTML manually, it renders the query inside a template called index.html. That template must sit inside the project file in a directory called templates. Create the directory, and put this in the index.html template: 

<!doctype html>

 

<html lang="en">

<head>

    <meta charset="utf-8">

    <title>RetroNerds</title>

    <meta name="description" content="Old Games for Old Geeks">

    <meta name="author" content="IT Pro">

    <style>

      .hidden {display:none}

    </style>

</head>

 

<body>

    <ul id="games">

      {% for g in games %}

      <li><a id="{{ g.id }}" href="#">{{ g.title }}</a>

      </li>

      {% endfor %}

    </ul>

 

// controls

 

// form

 

// JavaScript

    <script>

    </script>

</body>

</html>

The key part here is inside the <ul> unordered list. Flask uses a templating language called Jinja, which we use to loop over the games in the query results that we passed from our index function. It puts each of the games' titles in a list element (<a>). 

Stop your Flask app in the terminal if it's still running, and enter export FLASK_ENV=development in Linux or $env:FLASK_ENV = "development"

in Windows Powershell. This puts Flask into development mode so that you can see any changes to your code without restarting the app. It also displays useful debug data if your app fails. Now run your app with python3 retronerds.py and visit the URL it gives you. Your game titles should now have been added to your template. 

However, we still need to read the rest of the details for each record, and we need to create and delete new records. We'll use a single form for all these tasks. We'll insert the code for this form below the // form comment in our file: 

<form id="form" class="hidden">

    <label for="title">Title: </label>

    <input type="text" id="title" name="title" /><br><br>

    <label for="description">Description: </label>

    <textarea id="description" name="description"></textarea><br><br>

    <label for="yor">Year: </label>

    <select id="yor" name="yor">

      <script>

  let dateDropdown = document.getElementById('yor'); 

  let yearCounter = 1997;

  while (yearCounter >= 1970) {      

    let dateOption = document.createElement('option');          

    dateOption.text = yearCounter;      

    dateOption.value = yearCounter;        

    dateDropdown.add(dateOption);      

    yearCounter -= 1;    

  }

      </script>

    </select><br>

    <label for="publisher">Publisher: </label>

    <input type="text" id="publisher" name="publisher" /><br><br>

    <label for="genre">Genre: </label>

    <select id="genre" name="genre">

      <option value="Adventure">Adventure</option>

      <option value="Platform">Platform</option>

      <option value="Shooter">Shooter</option>

      <option value="Strategy">Strategy</option>

    </select><br><br>

    <input type="submit" id="create" value="Create" />

    <input type="submit" id="update" value="Update" />    

    <input type="submit" id="delete" value="Delete" />    

    <input type="reset" id="cancel" value="Cancel" onclick="hideform();"/>

</form>

The embedded JavaScript generates the years for which we're tracking games dynamically in our dropdown. 

Note that the form has a hidden class, which we defined using the internal CSS code at the start of our document. Any element with that class is invisible, meaning we can turn it on and off at will. 

We'll set up the basic framework to turn our multi-purpose form on and off inside the <script> tabs we created in our // Javascript section. 

We'll start by defining constants reflecting the elements in our form, beginning with the form itself. This makes them easier to manipulate later: 

const myForm = document.getElementById("form");

const titleInput = document.getElementById('title');

const descriptionInput = document.getElementById('description');

const yorInput = document.getElementById('yor');

const publisherInput = document.getElementById('publisher');

const genreInput = document.getElementById('genre');

const createButton = document.getElementById("create");

const updateButton = document.getElementById("update");

const deleteButton = document.getElementById("delete");

const cancelButton = document.getElementById("cancel");

Now we'll create a function to hide and show the form. We can give it a button to create, update, or delete a record, making it a multi-purpose form. We display and hide the appropriate buttons based on a task parameter that we pass to this function. If we're creating a form, then we'll ensure it's cleared and that only the createButton is showing (along with the cancel button, which is always displayed). Note that this form also recognises a read task. That's because we’re also using this form to display game records, giving the viewer the chance to update them. 

function showform(task) {

    if (task == 'create') {

myForm.reset();

createButton.setAttribute("class", "");

updateButton.setAttribute("class", "hidden");

    }

    if (task == 'read') {

createButton.setAttribute("class", "hidden");

updateButton.setAttribute("class", "");

    }

    myForm.setAttribute("class", "");

}

Finally, we'll create a function to hide our form. 

function hideform() {

   myForm.reset();

   myForm.setAttribute("class", "hidden");

 }

Notice that our cancel button already has an onclick event handler to execute this function.

Now we'll use this to display the form with the necessary controls to create a record. We'll do this with a link that will open the form. This goes in the HTML section of the page, just underneath the // controls section:

<div id="newgame"><a href="#" onclick="showform('create')">New Game</a></div>

When the user clicks on this, the form will appear with a create button. 

Creating 

To manipulate our records, including creating them, we'll use a technique called asynchronous JavaScript and XML (AJAX). This helps us to organise our requests in the browser, send them to the server, and then update the display on the browser side. 

The advantage of doing it this way is that we won't have to query the database after creating, updating, or reading a record. We won't have to reload the entire web page after these actions, either. This creates a smoother experience for the user, who won’t have to wait while the page refreshes. Instead, things will look more seamless and instant, as new information will appear on the page straight away. 

To make this happen, we'll update specific records in the browser using a JavaScript API called Fetch. This sends and retrieves records from a server using JSON, which is a standard format for exchanging data between web apps and browsers. 

We trigger the function when our createButton is clicked. Although it's a form submission button, we'll grab control of it so that our JavaScript can define what it does. 

createButton.onclick = function (e) {

    e.preventDefault();

Now we use fetch to capture the form input and encode it in JSON format: 

const response = fetch('/games/create', {

method: 'POST',

body: JSON.stringify({

    'title': titleInput.value,

    'description': descriptionInput.value,

    'yor': yorInput.value,

    'publisher': publisherInput.value,

    'genre': genreInput.value

}),

headers: {

  'Content-Type': 'application/json',

}

})

fetch points to what looks like a folder on the server called /games/create/. This is another application route, which tells the server that we want to create a file. Before we write that code, we'll finish this function by defining what we want to do with the response from the server. 

The fetch API represents the server's response as an object called a ‘promise’, which handles all the timeout and callback housekeeping you'd normally have to do when dealing synchronously with the server. It's called response, and we can turn it into a usable format by passing it through a json method. 

From there, we create an <a> tag in JavaScript, populate it with the response's title property, and give it the ID that the server sent back. That gives us enough information to list it dynamically on the page as a new game, all without reloading the page: 

.then(response => response.json())

.then(jsonResponse => {

    const li = document.createElement("li");

    const a = document.createElement("a");

    a.textContent = jsonResponse.title;

    a.setAttribute("id", jsonResponse.id);

    a.setAttribute("href", "#");

    li.append(a);

    li.addEventListener("click", readGame)

    document.getElementById('games').appendChild(li);

})

hideform();

}

Now we must write the code for the controller on the server. We'll define this as a route, much like our existing index page route, only longer: 

@app.route('/games/create', methods=['POST'])

def create_game():

   try:

       title = request.get_json()['title']

       description = request.get_json()['description']

       yor = request.get_json()['yor']

       publisher = request.get_json()['publisher']

       genre = request.get_json()['genre']

       game = Game(title=title, description=description, yor=yor, publisher=publisher, genre=genre)

       db.session.add(game)

       db.session.commit()

   except:

       db.session.rollback()

   finally:

       return jsonify({'title':game.title, 'id':game.id})

       db.session.close()

Notice how this route mirrors the action defined in our form, meaning that it will be invoked on the server side by the fetch command. First, this route's function uses the request library that we imported at the beginning to gather values from the form's various fields. Then it creates an instance of the Game class and populates it with that data. 

That will be the record that SQLAlchemy's object-relational manager writes to the server. It commits that transaction to the database, unless there's an error, in which case it rolls back that transaction. If all goes well, it returns the title of the game as written and the ID of the new record in the database to the browser. 

Reading 

We've included all the buttons we need to create, update, and delete records, but what about reading them? To handle this, we'll set an event handler on each of the games listed on our page. When we click on a game, its details will appear in our form, and we'll get the opportunity to update its records. We can do this because we already used Jinja to set the ID of each title's <a> tag as its game's ID in the database. Here's the code to iterate over the items in the list: 

var li = document.getElementsByTagName("li");

for(var i = 0;i<li.length;i++){

    li[i].addEventListener("click", readGame)

}

When we click on a list item containing an <a> tag with an ID, it triggers the readgame function. This is why it was so important to include an id attribute and an event handler when adding the new game that we created to our list; it had to be clickable, otherwise we'd end up with a list of clickable ones and a non-interactive one at the end. 

Next, add the function to read game data to your JavaScript section: 

function readGame(e){

    const gameID = e.target.attributes.id.value

    const response = fetch('/games/' + gameID + '/read', {

method: 'POST',

body: JSON.stringify({

'title': titleInput.value,

'description': descriptionInput.value,

'yor': yorInput.value,

'publisher': publisherInput.value,

    'genre': genreInput.value

}),

headers: {

    'Content-Type': 'application/json',

}

    })

  .then(response => response.json())

  .then(jsonResponse => {

      titleInput.value = jsonResponse.title;

      descriptionInput.value = jsonResponse.description;

      yorInput.value = jsonResponse.yor;

      publisherInput.value = jsonResponse.publisher;

      genreInput.value = jsonResponse.genre;

  })

    updateButton.setAttribute("data-id", gameID);

    deleteButton.setAttribute("data-id", gameID);

    showform('read');

}

It's a pointer to another route, which queries the controller on the server and inserts its response into the relevant fields of the form. It also changes the data-id attributes of both the updateButton and the deleteButton to the ID of the game that we clicked on, which it got from the id attribute of the game's <a> tag. This is crucial for updating or deleting a game, because we have to send its ID to the controller route on the server. 

Updating 

When we click the update button, we’ll send the form's contents to the controller, which will use the update route to look up the corresponding record in the database and then replace its data with the new data that we entered into the form. Here's the JavaScript function to send that update: 

updateButton.onclick = function (e) {

    e.preventDefault();

    const gameID = updateButton.getAttribute("data-id");

    const response = fetch('/games/' + gameID + '/update', {

method: 'POST',

body: JSON.stringify({

    'title': titleInput.value,

    'description': descriptionInput.value,

    'yor': yorInput.value,

    'publisher': publisherInput.value,

    'genre': genreInput.value

}),

headers: {

    'Content-Type': 'application/json',

}

    })

  .then(response => response.json())

  .then(jsonResponse => {

      const gameBullet = document.getElementById(gameID);

      gameBullet.innerText = jsonResponse.title;

  })

 

    hideform();

}

We kick off the function when the update button is clicked. Notice that this time, the route in our fetch command has the game's ID inserted into it so that the server knows which game it is updating. When the response comes back, we also update the game's title on the page in case that was one of the fields that we changed. We do that by searching for the <a> tag with the game's ID and then changing its text. 

Here's the corresponding controller function on the server: 

@app.route('/games/<game_id>/update', methods=['POST'])

def update_game(game_id):

    try:

title = request.get_json()['title']

description = request.get_json()['description']

yor = request.get_json()['yor']

publisher = request.get_json()['publisher']

genre = request.get_json()['genre']

 

game=Game.query.get(game_id)

game.title = title

game.description = description

game.yor = yor

game.publisher = publisher

game.genre = genre

db.session.query(Game).filter_by(id=game_id).update \

    ({"title": title, "description": description, "yor": yor, \

    "publisher": publisher, "genre": genre}, synchronize_session='fetch')

db.session.commit()

    except:

       db.session.rollback()

    finally:

       return jsonify({

       'title':game.title,

       })

       db.session.close()

This code uses the familiar block structure for error handling. It extracts the form data sent to it in JSON format and then uses the game ID in the route to look up the appropriate record in the database, extracting that information into an instance of the Game class. It then replaces all of those properties with the new ones from the JSON file and uses SQLAlchemy to update the record with the new data. Finally, it returns the title for the view to update its list with. 

Deleting

All that remains now is the delete part of our CRUD application, which is relatively simple. On the client side, this goes into our JavaScript section: 

deleteButton.onclick = function (e) {

    e.preventDefault();

    const gameID = deleteButton.getAttribute("data-id");

    const response = fetch('/games/' + gameID + '/delete', {

method: 'DELETE'

    })

  .then(response => response.json())

  .then(jsonResponse => {

      const gameText = document.getElementById(gameID);

      gameText.parentNode.parentNode.removeChild(gameText.parentNode);

  })

    hideform();

}

Note the use of the DELETE method rather than POST. Once again, this function sends the game's ID in the request. When the server confirms that it's deleted the game in its JSON response, the function deletes the <li> element containing the game's <a> tag. 

@app.route('/games/<game_id>/delete', methods=['DELETE'])

def delete_game(game_id):

    try:

game=Game.query.get(game_id)

db.session.delete(game)

db.session.commit()

db.session.close()

    except:

error = True

db.session.rollback()

    finally:

db.session.close()

    return jsonify({'success':True})

All it needs to send back in the JSON response is confirmation. 

There are lots more tweaks that a competent developer should make to this app before putting it into production. Field input validation, displaying error messages, ordering the games alphabetically, and styling it properly with CSS are just a few items on the to-do list. Not to mention tackling security properly with a well-structured environment variable system so that you don't store secrets in the source code. 

Nevertheless, this demonstrates the basic functionality of an AJAX-enabled CRUD application. It has the bare bones of a web app that you could use to build more sophisticated controllers and more complex views. 

The source code below, listed in full, would be a useful place to begin tinkering and making your own improvements. Things to consider include splitting up the CRUD interactions into multiple views, so that you can have separate screens to read files that you don’t want to update. That would also enable you to support different classes of user that don’t have update or delete privileges, which would be another thing on the to-do list for a production-ready application.

Other enhancements would include adding more data for the games (including images) and also enhancing the underlying database schema to use multiple tables with joins. Having separate tables for publisher and genre would stop you having to store the same data in multiple fields, and also makes it easier to list games that span different genres and have multiple publishers..

There’s a lot more to learn about full stack development, and there are lots of different ways to code an application even as simple as this one. Hopefully, though, this will have given you a taste of what is a difficult but ultimately rewarding activity.

retronerds.py 

class Game(db.Model):

    __tablename__ = 'games'

    id = db.Column(db.Integer, primary_key=True)

    title = db.Column(db.String(255), nullable=False)

    description = db.Column(db.String(1024), nullable=False)

    yor = db.Column(db.Integer, nullable=False)

    publisher = db.Column(db.String(255), nullable=False)

    genre = db.Column(db.String(255), nullable=False)

 

db.create_all()

 

@app.route('/')

def index():

    return render_template('index.html', games=Game.query.all())

 

@app.route('/games/create', methods=['POST'])

def create_game():

   try:

       title = request.get_json()['title']

       description = request.get_json()['description']

       yor = request.get_json()['yor']

       publisher = request.get_json()['publisher']

       genre = request.get_json()['genre']

       game = Game(title=title, description=description, yor=yor, publisher=publisher, genre=genre)

       db.session.add(game)

       db.session.commit()

   except:

       db.session.rollback()

   finally:

       return jsonify({'title':game.title, 'id':game.id})

       db.session.close()

 

@app.route('/games/<game_id>/read', methods=['POST'])

def read_game(game_id):

    game=Game.query.get(game_id)

    return jsonify({

'title':game.title,

'description':game.description,

'yor':game.yor,

'publisher':game.publisher,

'genre':game.genre

    })

 

@app.route('/games/<game_id>/update', methods=['POST'])

def update_game(game_id):

    error = False

    try:

title = request.get_json()['title']

description = request.get_json()['description']

yor = request.get_json()['yor']

publisher = request.get_json()['publisher']

genre = request.get_json()['genre']

 

game=Game.query.get(game_id)

game.title = title

game.description = description

game.yor = yor

game.publisher = publisher

game.genre = genre

db.session.query(Game).filter_by(id=game_id).update ({"title": title, "description": description, "yor": yor, "publisher": publisher, "genre": genre}, synchronize_session='fetch')

db.session.commit()

    except:

       db.session.rollback()

    finally:

       return jsonify({

       'title':game.title,

       })

       db.session.close()

 

@app.route('/games/<game_id>/delete', methods=['DELETE'])

def delete_game(game_id):

    try:

game=Game.query.get(game_id)

db.session.delete(game)

db.session.commit()

db.session.close()

    except:

error = True

db.session.rollback()

    finally:

db.session.close()

    return jsonify({'success':True})

 

if __name__ == '__main__':

    app.run()

index.html 

<!doctype html>

 

<html lang="en">

<head>

  <meta charset="utf-8">

  <title>RetroNerds</title>

  <meta name="description" content="Old Games for Old Geeks">

  <meta name="author" content="IT Pro">

  <style>

    .hidden {display:none}

  </style>

</head>

 

<body>

  <ul id="games">

    {% for g in games %}

    <li><a id="{{ g.id }}" href="#">{{ g.title }}</a>

    </li>

    {% endfor %}

  </ul>

  <div id="newgame"><a href="#" onclick="showform('create')">New Game</a></div>

 

  <form id="form" class="hidden">

      <label for="title">Title: </label>

      <input type="text" id="title" name="title" /><br><br>

      <label for="description">Description: </label>

      <textarea id="description" name="description"></textarea><br><br>

      <label for="yor">Year: </label>

      <select id="yor" name="yor">

<script>

    let dateDropdown = document.getElementById('yor'); 

    let yearCounter = 1997;

    while (yearCounter >= 1970) {      

      let dateOption = document.createElement('option');          

      dateOption.text = yearCounter;      

      dateOption.value = yearCounter;        

      dateDropdown.add(dateOption);      

      yearCounter -= 1;    

    }

</script>

      </select><br>

      <label for="publisher">Publisher: </label>

      <input type="text" id="publisher" name="publisher" /><br><br>

      <label for="genre">Genre: </label>

      <select id="genre" name="genre">

<option value="Adventure">Adventure</option>

<option value="Platform">Platform</option>

<option value="Shooter">Shooter</option>

<option value="Strategy">Strategy</option>

      </select><br><br>

      <input type="submit" id="create" value="Create" />

      <input type="submit" id="update" value="Update" />    

      <input type="submit" id="delete" value="Delete" />    

      <input type="reset" id="cancel" value="Cancel" onclick="hideform();"/>

  </form>

 

  <script>

    const titleInput = document.getElementById('title');

    const descriptionInput = document.getElementById('description');

    const yorInput = document.getElementById('yor');

    const publisherInput = document.getElementById('publisher');

    const genreInput = document.getElementById('genre');

    const myForm = document.getElementById("form");

    const createButton = document.getElementById("create");

    const updateButton = document.getElementById("update");

    const deleteButton = document.getElementById("delete");

    const cancelButton = document.getElementById("cancel");

 

    function showform(task) {

if (task == 'create') {

    myForm.reset();

    createButton.setAttribute("class", "");

    updateButton.setAttribute("class", "hidden");

}

if (task == 'read') {

    createButton.setAttribute("class", "hidden");

    updateButton.setAttribute("class", "");

}

myForm.setAttribute("class", "");

    }

 

    function hideform() {

myForm.reset();

myForm.setAttribute("class", "hidden");

    }

 

    createButton.onclick = function (e) {

e.preventDefault();

const response = fetch('/games/create', {

method: 'POST',

body: JSON.stringify({

    'title': titleInput.value,

    'description': descriptionInput.value,

    'yor': yorInput.value,

    'publisher': publisherInput.value,

    'genre': genreInput.value

}),

headers: {

  'Content-Type': 'application/json',

}

})

    .then(response => response.json())

    .then(jsonResponse => {

const li = document.createElement("li");

const a = document.createElement("a");

a.textContent = jsonResponse.title;

a.setAttribute("id", jsonResponse.id);

a.setAttribute("href", "#");

li.append(a);

li.addEventListener("click", readGame)

document.getElementById('games').appendChild(li);

    })

    hideform();

    }

 

    var li = document.getElementsByTagName("li");

    for(var i = 0;i<li.length;i++){

li[i].addEventListener("click", readGame)

    }

 

    function readGame(e){

const gameID = e.target.attributes.id.value

const response = fetch('/games/' + gameID + '/read', {

    method: 'POST',

    body: JSON.stringify({

    'title': titleInput.value,

    'description': descriptionInput.value,

    'yor': yorInput.value,

    'publisher': publisherInput.value,

'genre': genreInput.value

    }),

    headers: {

'Content-Type': 'application/json',

    }

})

      .then(response => response.json())

      .then(jsonResponse => {

  titleInput.value = jsonResponse.title;

  descriptionInput.value = jsonResponse.description;

  yorInput.value = jsonResponse.yor;

  publisherInput.value = jsonResponse.publisher;

  genreInput.value = jsonResponse.genre;

      })

updateButton.setAttribute("data-id", gameID);

deleteButton.setAttribute("data-id", gameID);

showform('read');

    }

 

    updateButton.onclick = function (e) {

e.preventDefault();

const gameID = updateButton.getAttribute("data-id");

const response = fetch('/games/' + gameID + '/update', {

    method: 'POST',

    body: JSON.stringify({

'title': titleInput.value,

'description': descriptionInput.value,

'yor': yorInput.value,

'publisher': publisherInput.value,

'genre': genreInput.value

    }),

    headers: {

'Content-Type': 'application/json',

    }

})

      .then(response => response.json())

      .then(jsonResponse => {

  const gameBullet = document.getElementById(gameID);

  gameBullet.innerText = jsonResponse.title;

      })

 

hideform();

    }

 

    deleteButton.onclick = function (e) {

e.preventDefault();

const gameID = deleteButton.getAttribute("data-id");

const response = fetch('/games/' + gameID + '/delete', {

    method: 'DELETE'

})

      .then(response => response.json())

      .then(jsonResponse => {

  const gameText = document.getElementById(gameID);

  gameText.parentNode.parentNode.removeChild(gameText.parentNode);

      })

hideform();

    }

 

</script>

</body>

</html>

Featured Resources

The ultimate law enforcement agency guide to going mobile

Best practices for implementing a mobile device program

Free download

The business value of Red Hat OpenShift

Platform cost savings, ROI, and the challenges and opportunities of Red Hat OpenShift

Free download

Managing security and risk across the IT supply chain: A practical approach

Best practices for IT supply chain security

Free download

Digital remote monitoring and dispatch services’ impact on edge computing and data centres

Seven trends redefining remote monitoring and field service dispatch service requirements

Free download

Most Popular

Alibaba unveils custom Arm-based server chip
components

Alibaba unveils custom Arm-based server chip

19 Oct 2021
What is cyber warfare?
Security

What is cyber warfare?

15 Oct 2021
Windows 11 has problems with Oracle VirtualBox
Microsoft Windows

Windows 11 has problems with Oracle VirtualBox

5 Oct 2021